vivekchakraverty Claude Opus 4.8 commited on
Commit
31fa536
·
1 Parent(s): 1722c47

Add grounded blog-post generator pipeline

Browse files

Docker Space bundling self-hosted SearXNG + a Gradio app that turns
topic/keywords/brief into an illustrated .docx blog post:
search-term LLM -> SearXNG -> OpenPageRank ranking -> content extraction
-> blog writing -> FLUX.1-schnell images -> Qwen2.5-VL captions -> docx.
All paid inference is billed to the user's HF token entered in the UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

.gitattributes CHANGED
@@ -33,3 +33,10 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+
37
+ # Force LF for scripts/text so the Docker (Linux) build runs correctly
38
+ *.sh text eol=lf
39
+ *.py text eol=lf
40
+ *.yml text eol=lf
41
+ Dockerfile text eol=lf
42
+ start.sh text eol=lf
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .cache/
4
+ data/
5
+ *.docx
Dockerfile ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Blog Post Generator — HF Docker Space
2
+ # Bundles a self-hosted SearXNG (internal, 127.0.0.1:8080) + a Gradio app (public, 7860).
3
+ FROM searxng/searxng:latest
4
+
5
+ USER root
6
+
7
+ # --- system deps for trafilatura/lxml/Pillow (Alpine base) ---
8
+ RUN apk add --no-cache \
9
+ bash \
10
+ libxml2 \
11
+ libxslt \
12
+ jpeg \
13
+ zlib \
14
+ build-base \
15
+ libxml2-dev \
16
+ libxslt-dev \
17
+ jpeg-dev \
18
+ zlib-dev
19
+
20
+ # SearXNG installs into this virtualenv; reuse it for the app so we get one Python.
21
+ ENV VENV=/usr/local/searxng/searx-pyenv
22
+ ENV PATH="$VENV/bin:$PATH"
23
+ ENV SEARXNG_SETTINGS_PATH=/etc/searxng/settings.yml
24
+ # HF persistent storage mounts at /data; config.py falls back if absent.
25
+ ENV DATA_DIR=/data
26
+ ENV SEARXNG_URL=http://127.0.0.1:8080
27
+
28
+ WORKDIR /app
29
+
30
+ COPY requirements.txt .
31
+ RUN "$VENV/bin/pip" install --no-cache-dir -r requirements.txt
32
+
33
+ # App source
34
+ COPY app.py start.sh ./
35
+ COPY pipeline ./pipeline
36
+ COPY searxng/settings.yml /etc/searxng/settings.yml
37
+
38
+ # HF Spaces run the container as UID 1000 — make everything that gets written writable.
39
+ RUN chmod +x /app/start.sh \
40
+ && mkdir -p /data /app/.cache \
41
+ && chgrp -R 0 /app /etc/searxng /usr/local/searxng /data \
42
+ && chmod -R g+rwX /app /etc/searxng /usr/local/searxng /data
43
+
44
+ EXPOSE 7860
45
+
46
+ USER 1000
47
+
48
+ ENTRYPOINT ["/app/start.sh"]
README.md CHANGED
@@ -4,8 +4,54 @@ emoji: ⚡
4
  colorFrom: yellow
5
  colorTo: blue
6
  sdk: docker
 
7
  pinned: false
8
- short_description: Writes Blog POsts
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  colorFrom: yellow
5
  colorTo: blue
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ short_description: Illustrated blog-post generator, exported as .docx
10
  ---
11
 
12
+ # Blog Post Generator
13
+
14
+ Turn a **topic + keywords + brief** into a fully-written, illustrated blog post exported
15
+ as a `.docx` file. The pipeline is *grounded*: instead of writing blind, it runs real web
16
+ search, ranks the sources by authority, and writes from the most authoritative pages.
17
+
18
+ ## Pipeline
19
+
20
+ 1. **Search-term reasoning** — an LLM turns your topic/keywords/brief into optimized queries.
21
+ 2. **Web search** — a self-hosted [SearXNG](https://github.com/searxng/searxng) instance
22
+ (running inside this Space) returns the top ~25 result domains.
23
+ 3. **Authority ranking** — [OpenPageRank](https://www.domcop.com/openpagerank/documentation)
24
+ scores those domains; the top 5 pages are selected.
25
+ 4. **Content extraction** — main article text is extracted from those pages.
26
+ 5. **Writing** — a strong LLM writes an SEO-aware post from that material, weaving your
27
+ keywords in and marking where images belong.
28
+ 6. **Illustration** — image prompts are written by an LLM and rendered with
29
+ **FLUX.1 [schnell]**.
30
+ 7. **Captioning** — a vision model captions each generated image.
31
+ 8. **Export** — everything is assembled into a downloadable `.docx`.
32
+
33
+ ## Billing
34
+
35
+ All *paid* AI calls (reasoning, writing, image generation, captioning) run through
36
+ **Hugging Face Inference Providers using the HF token you enter in the UI**, so they are
37
+ billed to **you**. Nothing is stored server-side.
38
+
39
+ Get a token at <https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained>
40
+ (needs *"Make calls to Inference Providers"* permission).
41
+
42
+ ## Configuration (Space owner)
43
+
44
+ - **Secret `OPR_API_KEY`** — a free OpenPageRank API key
45
+ (<https://www.domcop.com/openpagerank/auth/signup>). Required for authority ranking.
46
+ - **Persistent storage** *(optional)* — if enabled, `/data` is used to cache search
47
+ results, scores, images and docx between runs. Without it the app still works; the cache
48
+ just lives in a local directory and is not persisted.
49
+
50
+ ## Models (fixed defaults)
51
+
52
+ | Step | Model |
53
+ | --- | --- |
54
+ | Search terms & image prompts | `openai/gpt-oss-120b` |
55
+ | Blog writing | `deepseek-ai/DeepSeek-V3-0324` (fallback `Qwen/Qwen2.5-72B-Instruct`) |
56
+ | Image generation | `black-forest-labs/FLUX.1-schnell` |
57
+ | Image captioning | `Qwen/Qwen2.5-VL-72B-Instruct` |
app.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio UI for the Blog Post Generator HF Space.
2
+
3
+ Inputs: topic, primary/secondary keyword, brief, and the user's HF token (password).
4
+ All paid AI calls are billed to that token. Outputs a live status log, a Markdown
5
+ preview, an image gallery, and a downloadable .docx.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import traceback
11
+
12
+ import gradio as gr
13
+
14
+ from pipeline import config, orchestrator
15
+
16
+ INTRO = """
17
+ # 📝 Blog Post Generator
18
+ Turn a topic + keywords + brief into a fully-written, **illustrated** blog post exported as `.docx`.
19
+
20
+ The pipeline: **search-term reasoning → self-hosted SearXNG web search → OpenPageRank
21
+ authority ranking → content extraction → LLM writing → FLUX.1-schnell images → vision
22
+ captioning → DOCX export.**
23
+
24
+ > All paid AI calls run through **Hugging Face Inference Providers using the token you
25
+ > enter below**, so they are billed to **you**. The token is used only for this run and
26
+ > is not stored.
27
+ """
28
+
29
+
30
+ def _preview(markdown: str, images: list) -> str:
31
+ """Replace [IMAGE:] markers with their caption text for a readable Markdown preview."""
32
+ imgs = [im for im in images if im.get("path")]
33
+ it = iter(imgs)
34
+
35
+ def repl(_m):
36
+ im = next(it, None)
37
+ cap = (im or {}).get("caption") or (im or {}).get("scene") or "image"
38
+ return f"\n> 🖼️ *{cap}*\n"
39
+
40
+ return re.sub(r"\[IMAGE:\s*.+?\]", repl, markdown, flags=re.DOTALL)
41
+
42
+
43
+ def generate(topic, primary, secondary, brief, hf_token, progress=gr.Progress()):
44
+ log_lines = []
45
+ gallery, docx_file, preview = [], None, ""
46
+
47
+ def status():
48
+ return "\n".join(f"- {ln}" for ln in log_lines)
49
+
50
+ try:
51
+ for frac, message, result in orchestrator.run(
52
+ hf_token, topic, primary, secondary, brief
53
+ ):
54
+ progress(frac, desc=message)
55
+ log_lines.append(message)
56
+
57
+ if result.get("images"):
58
+ gallery = [
59
+ (im["path"], im.get("caption") or im.get("scene", ""))
60
+ for im in result["images"]
61
+ if im.get("path")
62
+ ]
63
+ if result.get("markdown"):
64
+ preview = _preview(result["markdown"], result.get("images", []))
65
+ if result.get("docx_path"):
66
+ docx_file = result["docx_path"]
67
+
68
+ yield status(), preview, gallery, docx_file
69
+ except Exception as e: # noqa: BLE001 - show the user a clean error
70
+ log_lines.append(f"❌ **Error:** {e}")
71
+ traceback.print_exc()
72
+ yield status(), preview, gallery, docx_file
73
+
74
+
75
+ def build_ui() -> gr.Blocks:
76
+ with gr.Blocks(title="Blog Post Generator", theme=gr.themes.Soft()) as demo:
77
+ gr.Markdown(INTRO)
78
+ with gr.Row():
79
+ with gr.Column(scale=1):
80
+ topic = gr.Textbox(label="Topic", placeholder="e.g. Home composting for beginners")
81
+ primary = gr.Textbox(label="Primary keyword", placeholder="e.g. home composting")
82
+ secondary = gr.Textbox(label="Secondary keyword", placeholder="e.g. kitchen scraps")
83
+ brief = gr.Textbox(
84
+ label="Brief",
85
+ lines=5,
86
+ placeholder="Audience, angle, tone, must-cover points…",
87
+ )
88
+ hf_token = gr.Textbox(
89
+ label="Hugging Face token (billed to you)",
90
+ type="password",
91
+ placeholder="hf_...",
92
+ )
93
+ run_btn = gr.Button("Generate blog post", variant="primary")
94
+ with gr.Column(scale=2):
95
+ status_box = gr.Markdown(label="Progress")
96
+ docx_out = gr.File(label="Download .docx")
97
+ gallery = gr.Gallery(label="Generated images", columns=2, height=320)
98
+ preview = gr.Markdown(label="Preview")
99
+
100
+ run_btn.click(
101
+ fn=generate,
102
+ inputs=[topic, primary, secondary, brief, hf_token],
103
+ outputs=[status_box, preview, gallery, docx_out],
104
+ )
105
+ gr.Markdown(
106
+ f"Models: writer `{config.MODEL_WRITER}` · reasoning `{config.MODEL_REASONING}` · "
107
+ f"images `{config.MODEL_IMAGE}` · vision `{config.MODEL_VISION}`."
108
+ )
109
+ return demo
110
+
111
+
112
+ if __name__ == "__main__":
113
+ build_ui().queue().launch(server_name="0.0.0.0", server_port=7860)
pipeline/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Blog Post Generator pipeline package."""
pipeline/cache.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tiny keyed cache under DATA_DIR for JSON-serializable pipeline artifacts.
2
+
3
+ Used to avoid re-running search / ranking / extraction for identical inputs. Image
4
+ and docx binaries are written directly under OUT_DIR by their own modules; this
5
+ handles the lightweight JSON steps.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ from typing import Any, Optional
12
+
13
+ from . import config
14
+
15
+
16
+ def run_key(*parts: str) -> str:
17
+ """Stable short hash identifying a run from its inputs."""
18
+ joined = " ".join(p or "" for p in parts)
19
+ h = hashlib.sha256(joined.encode("utf-8"))
20
+ return h.hexdigest()[:16]
21
+
22
+
23
+ def _path(key: str, name: str):
24
+ return config.CACHE_DIR / f"{key}.{name}.json"
25
+
26
+
27
+ def get(key: str, name: str) -> Optional[Any]:
28
+ p = _path(key, name)
29
+ if p.exists():
30
+ try:
31
+ return json.loads(p.read_text(encoding="utf-8"))
32
+ except Exception:
33
+ return None
34
+ return None
35
+
36
+
37
+ def put(key: str, name: str, value: Any) -> None:
38
+ p = _path(key, name)
39
+ try:
40
+ p.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
41
+ except Exception:
42
+ # Cache is best-effort; never fail the pipeline over it.
43
+ pass
pipeline/captions.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 7: caption each generated image with a vision-language model."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import mimetypes
6
+ from pathlib import Path
7
+ from typing import List
8
+
9
+ from huggingface_hub import InferenceClient
10
+
11
+ from . import config
12
+
13
+ _CAPTION_PROMPT = (
14
+ "Write a concise, engaging one-sentence caption for this blog illustration. "
15
+ "Describe what is shown; do not start with 'This image' or 'A picture of'. "
16
+ "Return only the caption."
17
+ )
18
+
19
+
20
+ def _data_uri(path: Path) -> str:
21
+ mime = mimetypes.guess_type(str(path))[0] or "image/png"
22
+ b64 = base64.b64encode(path.read_bytes()).decode("utf-8")
23
+ return f"data:{mime};base64,{b64}"
24
+
25
+
26
+ def caption_images(client: InferenceClient, images: List[dict]) -> List[dict]:
27
+ """Add a 'caption' key to each image dict that has a valid 'path'."""
28
+ for item in images:
29
+ path = item.get("path")
30
+ if not path or not Path(path).exists():
31
+ item["caption"] = ""
32
+ continue
33
+ try:
34
+ resp = client.chat.completions.create(
35
+ model=config.MODEL_VISION,
36
+ messages=[
37
+ {
38
+ "role": "user",
39
+ "content": [
40
+ {"type": "text", "text": _CAPTION_PROMPT},
41
+ {"type": "image_url", "image_url": {"url": _data_uri(Path(path))}},
42
+ ],
43
+ }
44
+ ],
45
+ max_tokens=80,
46
+ temperature=0.5,
47
+ )
48
+ item["caption"] = (resp.choices[0].message.content or "").strip().strip('"')
49
+ except Exception:
50
+ # fall back to the scene description if captioning fails
51
+ item["caption"] = item.get("scene", "")
52
+ return images
pipeline/config.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Central configuration: model ids, storage paths, and pipeline constants.
2
+
3
+ Everything tunable lives here so models/limits can be swapped in one place.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from pathlib import Path
9
+
10
+ # --- Models (all served via HF Inference Providers, billed to the user's token) ---
11
+ # Fast, capable general model used for reasoning-style tasks (search terms, image prompts).
12
+ MODEL_REASONING = os.environ.get("MODEL_REASONING", "openai/gpt-oss-120b")
13
+ # Strong long-form writer for the actual blog post, with a fallback if unavailable.
14
+ MODEL_WRITER = os.environ.get("MODEL_WRITER", "deepseek-ai/DeepSeek-V3-0324")
15
+ MODEL_WRITER_FALLBACK = os.environ.get("MODEL_WRITER_FALLBACK", "Qwen/Qwen2.5-72B-Instruct")
16
+ # Text-to-image model (as requested).
17
+ MODEL_IMAGE = os.environ.get("MODEL_IMAGE", "black-forest-labs/FLUX.1-schnell")
18
+ # Vision-language model for captioning generated images.
19
+ MODEL_VISION = os.environ.get("MODEL_VISION", "Qwen/Qwen2.5-VL-72B-Instruct")
20
+
21
+ # --- External services ---
22
+ SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://127.0.0.1:8080")
23
+ OPR_API_KEY = os.environ.get("OPR_API_KEY", "")
24
+ OPR_ENDPOINT = "https://openpagerank.com/api/v1.0/getPageRank"
25
+
26
+ # --- Pipeline constants ---
27
+ N_SEARCH_TERMS = int(os.environ.get("N_SEARCH_TERMS", "5")) # queries generated by the LLM
28
+ TOP_N = int(os.environ.get("TOP_N", "25")) # results ranked by OpenPageRank
29
+ TOP_K = int(os.environ.get("TOP_K", "5")) # top pages used as source material
30
+ N_IMAGES = int(os.environ.get("N_IMAGES", "3")) # illustrations per post
31
+ SOURCE_CHAR_CAP = int(os.environ.get("SOURCE_CHAR_CAP", "4000")) # chars kept per source page
32
+ HTTP_TIMEOUT = int(os.environ.get("HTTP_TIMEOUT", "20")) # seconds for outbound HTTP
33
+
34
+
35
+ def _resolve_data_dir() -> Path:
36
+ """Prefer HF persistent storage (/data); fall back to a local dir if not writable."""
37
+ candidate = Path(os.environ.get("DATA_DIR", "/data"))
38
+ try:
39
+ candidate.mkdir(parents=True, exist_ok=True)
40
+ probe = candidate / ".write_test"
41
+ probe.write_text("ok", encoding="utf-8")
42
+ probe.unlink()
43
+ return candidate
44
+ except Exception:
45
+ fallback = Path(__file__).resolve().parent.parent / ".cache"
46
+ fallback.mkdir(parents=True, exist_ok=True)
47
+ return fallback
48
+
49
+
50
+ DATA_DIR = _resolve_data_dir()
51
+ CACHE_DIR = DATA_DIR / "cache"
52
+ OUT_DIR = DATA_DIR / "out"
53
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
54
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
pipeline/docx_builder.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 8: assemble the Markdown post + images + captions into a .docx file."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from pathlib import Path
6
+ from typing import List
7
+
8
+ from docx import Document
9
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
10
+ from docx.shared import Inches, Pt, RGBColor
11
+
12
+ _IMAGE_MARKER = re.compile(r"\[IMAGE:\s*.+?\]", re.DOTALL)
13
+ _INLINE = re.compile(r"(\*\*.+?\*\*|\*.+?\*|`.+?`)")
14
+ _MAX_IMG_WIDTH = Inches(6.0)
15
+
16
+
17
+ def build_docx(markdown: str, images: List[dict], out_path: Path) -> Path:
18
+ """Render `markdown` to a Word document, inserting `images` at [IMAGE:] markers in order."""
19
+ doc = Document()
20
+ img_iter = iter([im for im in images if im.get("path")])
21
+
22
+ for block in _split_blocks(markdown):
23
+ if _IMAGE_MARKER.fullmatch(block.strip()):
24
+ _insert_next_image(doc, img_iter)
25
+ continue
26
+ # a block may still contain an inline marker mixed with text
27
+ if _IMAGE_MARKER.search(block):
28
+ for piece in _IMAGE_MARKER.split(block):
29
+ if piece.strip():
30
+ _render_line(doc, piece.strip())
31
+ _insert_next_image(doc, img_iter)
32
+ continue
33
+ _render_line(doc, block)
34
+
35
+ # any leftover images that never got placed → append at the end
36
+ for im in img_iter:
37
+ _add_picture(doc, im)
38
+
39
+ out_path.parent.mkdir(parents=True, exist_ok=True)
40
+ doc.save(out_path)
41
+ return out_path
42
+
43
+
44
+ def _split_blocks(md: str) -> List[str]:
45
+ """Split into logical blocks (headings/paragraphs/markers), preserving order."""
46
+ blocks: List[str] = []
47
+ for raw in md.split("\n"):
48
+ line = raw.rstrip()
49
+ if line.strip() == "":
50
+ continue
51
+ blocks.append(line)
52
+ return blocks
53
+
54
+
55
+ def _render_line(doc: Document, line: str) -> None:
56
+ stripped = line.strip()
57
+ if stripped.startswith("#"):
58
+ level = len(stripped) - len(stripped.lstrip("#"))
59
+ text = stripped[level:].strip()
60
+ doc.add_heading(text, level=min(max(level, 1), 4))
61
+ elif re.match(r"^[-*]\s+", stripped):
62
+ text = re.sub(r"^[-*]\s+", "", stripped)
63
+ p = doc.add_paragraph(style="List Bullet")
64
+ _add_runs(p, text)
65
+ elif re.match(r"^\d+\.\s+", stripped):
66
+ text = re.sub(r"^\d+\.\s+", "", stripped)
67
+ p = doc.add_paragraph(style="List Number")
68
+ _add_runs(p, text)
69
+ elif stripped.startswith(">"):
70
+ p = doc.add_paragraph(style="Intense Quote")
71
+ _add_runs(p, stripped.lstrip("> ").strip())
72
+ else:
73
+ p = doc.add_paragraph()
74
+ _add_runs(p, stripped)
75
+
76
+
77
+ def _add_runs(paragraph, text: str) -> None:
78
+ """Add text to a paragraph, honoring **bold**, *italic*, and `code`."""
79
+ for token in _INLINE.split(text):
80
+ if not token:
81
+ continue
82
+ if token.startswith("**") and token.endswith("**"):
83
+ paragraph.add_run(token[2:-2]).bold = True
84
+ elif token.startswith("*") and token.endswith("*"):
85
+ paragraph.add_run(token[1:-1]).italic = True
86
+ elif token.startswith("`") and token.endswith("`"):
87
+ run = paragraph.add_run(token[1:-1])
88
+ run.font.name = "Consolas"
89
+ else:
90
+ paragraph.add_run(token)
91
+
92
+
93
+ def _insert_next_image(doc: Document, img_iter) -> None:
94
+ im = next(img_iter, None)
95
+ if im is not None:
96
+ _add_picture(doc, im)
97
+
98
+
99
+ def _add_picture(doc: Document, im: dict) -> None:
100
+ path = im.get("path")
101
+ if not path or not Path(path).exists():
102
+ return
103
+ try:
104
+ p = doc.add_paragraph()
105
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
106
+ p.add_run().add_picture(str(path), width=_MAX_IMG_WIDTH)
107
+ except Exception:
108
+ return
109
+ caption = (im.get("caption") or "").strip()
110
+ if caption:
111
+ cap = doc.add_paragraph()
112
+ cap.alignment = WD_ALIGN_PARAGRAPH.CENTER
113
+ run = cap.add_run(caption)
114
+ run.italic = True
115
+ run.font.size = Pt(9)
116
+ run.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
pipeline/extract.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 4: fetch the top-ranked pages and extract clean article text."""
2
+ from __future__ import annotations
3
+
4
+ from typing import List
5
+
6
+ import trafilatura
7
+
8
+ from . import config
9
+
10
+
11
+ def extract_sources(results: List[dict]) -> List[dict]:
12
+ """For each result, download + extract main text (capped). Skips pages that fail.
13
+
14
+ Adds 'text' to each result dict and returns only those with usable content.
15
+ """
16
+ sources: List[dict] = []
17
+ for r in results:
18
+ url = r.get("url")
19
+ if not url:
20
+ continue
21
+ text = _extract_one(url)
22
+ if not text:
23
+ # fall back to the search snippet so the source still contributes something
24
+ text = (r.get("snippet") or "").strip()
25
+ if not text:
26
+ continue
27
+ r = dict(r)
28
+ r["text"] = text[: config.SOURCE_CHAR_CAP]
29
+ sources.append(r)
30
+ return sources
31
+
32
+
33
+ def _extract_one(url: str) -> str:
34
+ try:
35
+ downloaded = trafilatura.fetch_url(url)
36
+ if not downloaded:
37
+ return ""
38
+ text = trafilatura.extract(
39
+ downloaded,
40
+ include_comments=False,
41
+ include_tables=False,
42
+ no_fallback=False,
43
+ favor_precision=True,
44
+ )
45
+ return (text or "").strip()
46
+ except Exception:
47
+ return ""
pipeline/images.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 6: turn each [IMAGE: ...] marker into a FLUX prompt and render it."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import List
6
+
7
+ from huggingface_hub import InferenceClient
8
+
9
+ from . import config, llm
10
+
11
+ _PROMPT_SYSTEM = (
12
+ "You are a prompt engineer for the FLUX text-to-image model. Given a short scene "
13
+ "description for a blog illustration and the article topic, write ONE vivid, concrete "
14
+ "image prompt (single line, <60 words). Describe subject, setting, composition, "
15
+ "lighting and style. Prefer clean, editorial, photographic or tasteful illustrative "
16
+ "styles suitable for a professional blog. No text/words in the image. Return only the prompt."
17
+ )
18
+
19
+
20
+ def _flux_prompt(client: InferenceClient, topic: str, scene: str) -> str:
21
+ try:
22
+ p = llm.chat(
23
+ client,
24
+ config.MODEL_REASONING,
25
+ _PROMPT_SYSTEM,
26
+ f"Article topic: {topic}\nScene: {scene}\nWrite the FLUX prompt.",
27
+ max_tokens=150,
28
+ temperature=0.8,
29
+ )
30
+ p = p.strip().strip('"')
31
+ return p or scene
32
+ except Exception:
33
+ return f"{scene}, editorial photography, clean composition, natural lighting"
34
+
35
+
36
+ def generate_images(
37
+ client: InferenceClient,
38
+ topic: str,
39
+ scenes: List[str],
40
+ run_dir: Path,
41
+ ) -> List[dict]:
42
+ """Render one image per scene. Returns [{scene, prompt, path|None, error?}]."""
43
+ run_dir.mkdir(parents=True, exist_ok=True)
44
+ out: List[dict] = []
45
+ for i, scene in enumerate(scenes):
46
+ prompt = _flux_prompt(client, topic, scene)
47
+ item = {"scene": scene, "prompt": prompt, "path": None}
48
+ try:
49
+ image = client.text_to_image(prompt=prompt, model=config.MODEL_IMAGE)
50
+ path = run_dir / f"image_{i + 1}.png"
51
+ image.save(path)
52
+ item["path"] = str(path)
53
+ except Exception as e: # noqa: BLE001 - one bad image shouldn't kill the run
54
+ item["error"] = str(e)
55
+ out.append(item)
56
+ return out
pipeline/llm.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared helpers for talking to HF Inference Providers with the user's token.
2
+
3
+ Every call here is billed to whoever owns `hf_token`. A single InferenceClient is
4
+ built per request in app.py and threaded through the pipeline.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import List, Optional
9
+
10
+ from huggingface_hub import InferenceClient
11
+
12
+
13
+ def make_client(hf_token: str) -> InferenceClient:
14
+ """Build an InferenceClient bound to the user's token (auto provider selection)."""
15
+ token = (hf_token or "").strip()
16
+ if not token:
17
+ raise ValueError("A Hugging Face token is required (paid calls are billed to it).")
18
+ return InferenceClient(token=token)
19
+
20
+
21
+ def chat(
22
+ client: InferenceClient,
23
+ model: str,
24
+ system: str,
25
+ user: str,
26
+ *,
27
+ max_tokens: int = 1024,
28
+ temperature: float = 0.7,
29
+ fallback_model: Optional[str] = None,
30
+ ) -> str:
31
+ """Run a chat completion and return the assistant text.
32
+
33
+ Falls back to `fallback_model` once if the primary model errors (e.g. no provider).
34
+ """
35
+ messages = [
36
+ {"role": "system", "content": system},
37
+ {"role": "user", "content": user},
38
+ ]
39
+ models: List[str] = [model] + ([fallback_model] if fallback_model else [])
40
+ last_err: Optional[Exception] = None
41
+ for m in models:
42
+ try:
43
+ resp = client.chat.completions.create(
44
+ model=m,
45
+ messages=messages,
46
+ max_tokens=max_tokens,
47
+ temperature=temperature,
48
+ )
49
+ return (resp.choices[0].message.content or "").strip()
50
+ except Exception as e: # noqa: BLE001 - surface a clean error after trying fallback
51
+ last_err = e
52
+ continue
53
+ raise RuntimeError(f"LLM call failed for {models}: {last_err}")
pipeline/openpagerank.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 3: rank the candidate domains by authority using OpenPageRank.
2
+
3
+ API: GET https://openpagerank.com/api/v1.0/getPageRank
4
+ Auth: header API-OPR: <key>
5
+ Params: repeated domains[]=<domain> (up to 100 per call)
6
+ Response: response[] with page_rank_decimal / page_rank_integer / rank per domain.
7
+ Docs: https://www.domcop.com/openpagerank/documentation
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Dict, List
12
+
13
+ import requests
14
+
15
+ from . import config
16
+
17
+
18
+ def _chunks(items: List[str], size: int = 100):
19
+ for i in range(0, len(items), size):
20
+ yield items[i : i + size]
21
+
22
+
23
+ def fetch_pageranks(domains: List[str]) -> Dict[str, float]:
24
+ """Return {domain: page_rank_decimal}. Missing/unknown domains map to 0.0.
25
+
26
+ Raises RuntimeError if the API key is missing so the caller can surface it.
27
+ """
28
+ if not config.OPR_API_KEY:
29
+ raise RuntimeError(
30
+ "OPR_API_KEY is not set. The Space owner must add an OpenPageRank API key "
31
+ "(free at domcop.com) as a Space secret."
32
+ )
33
+ scores: Dict[str, float] = {d: 0.0 for d in domains}
34
+ headers = {"API-OPR": config.OPR_API_KEY}
35
+ for chunk in _chunks(domains, 100):
36
+ params = [("domains[]", d) for d in chunk]
37
+ try:
38
+ r = requests.get(
39
+ config.OPR_ENDPOINT,
40
+ params=params,
41
+ headers=headers,
42
+ timeout=config.HTTP_TIMEOUT,
43
+ )
44
+ r.raise_for_status()
45
+ payload = r.json()
46
+ except Exception as e: # noqa: BLE001
47
+ raise RuntimeError(f"OpenPageRank request failed: {e}") from e
48
+
49
+ for item in payload.get("response", []) or []:
50
+ dom = (item.get("domain") or "").lower()
51
+ if dom not in scores:
52
+ continue
53
+ try:
54
+ scores[dom] = float(item.get("page_rank_decimal") or 0.0)
55
+ except (TypeError, ValueError):
56
+ scores[dom] = 0.0
57
+ return scores
58
+
59
+
60
+ def rank_results(results: List[dict], top_k: int = config.TOP_K) -> List[dict]:
61
+ """Attach OpenPageRank scores to SearXNG results and return the top-K by authority.
62
+
63
+ Falls back to the incoming (SearXNG) order if the API is unavailable.
64
+ """
65
+ domains = [r["domain"] for r in results]
66
+ try:
67
+ scores = fetch_pageranks(domains)
68
+ for r in results:
69
+ r["page_rank"] = scores.get(r["domain"], 0.0)
70
+ ranked = sorted(results, key=lambda x: x.get("page_rank", 0.0), reverse=True)
71
+ except RuntimeError:
72
+ # Preserve SearXNG order; mark scores as unknown.
73
+ for r in results:
74
+ r.setdefault("page_rank", None)
75
+ ranked = results
76
+ return ranked[:top_k]
pipeline/orchestrator.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end orchestration of the blog-post pipeline.
2
+
3
+ Kept UI-agnostic: `run()` is a generator that yields (progress_fraction, message,
4
+ partial_result) tuples so any front-end can drive a progress bar. The final yield
5
+ carries the complete result dict.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Iterator, Tuple
11
+
12
+ from . import (
13
+ cache,
14
+ captions,
15
+ config,
16
+ extract,
17
+ images,
18
+ llm,
19
+ openpagerank,
20
+ search_terms,
21
+ searxng_client,
22
+ writer,
23
+ )
24
+
25
+
26
+ def run(
27
+ hf_token: str,
28
+ topic: str,
29
+ primary_keyword: str,
30
+ secondary_keyword: str,
31
+ brief: str,
32
+ ) -> Iterator[Tuple[float, str, dict]]:
33
+ if not topic.strip():
34
+ raise ValueError("Please enter a topic.")
35
+ client = llm.make_client(hf_token) # raises if token missing → billed to user
36
+
37
+ key = cache.run_key(topic, primary_keyword, secondary_keyword, brief)
38
+ run_dir = config.OUT_DIR / key
39
+ run_dir.mkdir(parents=True, exist_ok=True)
40
+ result: dict = {"key": key}
41
+
42
+ # 1) search terms
43
+ yield 0.05, "Generating search terms…", result
44
+ terms = cache.get(key, "terms") or search_terms.generate_search_terms(
45
+ client, topic, primary_keyword, secondary_keyword, brief
46
+ )
47
+ cache.put(key, "terms", terms)
48
+ result["terms"] = terms
49
+
50
+ # 2) SearXNG search
51
+ yield 0.15, f"Searching the web for {len(terms)} queries…", result
52
+ candidates = cache.get(key, "candidates") or searxng_client.search(terms, config.TOP_N)
53
+ if not candidates:
54
+ raise RuntimeError(
55
+ "No search results from SearXNG. Is the SearXNG service running "
56
+ "(check the Space logs)?"
57
+ )
58
+ cache.put(key, "candidates", candidates)
59
+ result["candidates"] = candidates
60
+
61
+ # 3) OpenPageRank authority ranking
62
+ yield 0.30, f"Ranking {len(candidates)} domains by OpenPageRank…", result
63
+ top = openpagerank.rank_results(candidates, config.TOP_K)
64
+ cache.put(key, "top", top)
65
+ result["top"] = top
66
+
67
+ # 4) extract source material
68
+ yield 0.40, f"Extracting content from top {len(top)} pages…", result
69
+ sources = extract.extract_sources(top)
70
+ if not sources:
71
+ raise RuntimeError("Could not extract readable content from the top pages.")
72
+ result["sources"] = [{k: v for k, v in s.items() if k != "text"} for s in sources]
73
+
74
+ # 5) write the post
75
+ yield 0.55, "Writing the blog post…", result
76
+ markdown = writer.write_post(
77
+ client, topic, primary_keyword, secondary_keyword, brief, sources
78
+ )
79
+ (run_dir / "post.md").write_text(markdown, encoding="utf-8")
80
+ result["markdown"] = markdown
81
+
82
+ # 6) generate images
83
+ scenes = writer.parse_image_markers(markdown)
84
+ yield 0.70, f"Generating {len(scenes)} images with FLUX.1-schnell…", result
85
+ imgs = images.generate_images(client, topic, scenes, run_dir)
86
+ result["images"] = imgs
87
+
88
+ # 7) caption images
89
+ yield 0.85, "Captioning images…", result
90
+ imgs = captions.caption_images(client, imgs)
91
+ result["images"] = imgs
92
+
93
+ # 8) build docx
94
+ yield 0.95, "Building the .docx file…", result
95
+ from . import docx_builder
96
+
97
+ docx_path = docx_builder.build_docx(markdown, imgs, run_dir / "blog.docx")
98
+ result["docx_path"] = str(docx_path)
99
+
100
+ yield 1.0, "Done.", result
pipeline/search_terms.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 1: LLM turns the topic/keywords/brief into optimized web-search queries."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from typing import List
7
+
8
+ from huggingface_hub import InferenceClient
9
+
10
+ from . import config, llm
11
+
12
+ _SYSTEM = (
13
+ "You are an SEO research assistant. Given a blog topic, its target keywords, and a "
14
+ "brief, produce a small set of high-signal web search queries that will surface the "
15
+ "most authoritative, information-rich sources to write the post from. Vary angle and "
16
+ "specificity. Return ONLY a JSON array of query strings, nothing else."
17
+ )
18
+
19
+
20
+ def _fallback_terms(topic: str, primary: str, secondary: str) -> List[str]:
21
+ base = [t for t in [topic, primary, secondary] if t]
22
+ extra = [f"{topic} {primary}".strip(), f"{topic} guide", f"{topic} best practices"]
23
+ seen, out = set(), []
24
+ for t in base + extra:
25
+ t = t.strip()
26
+ if t and t.lower() not in seen:
27
+ seen.add(t.lower())
28
+ out.append(t)
29
+ return out[: config.N_SEARCH_TERMS]
30
+
31
+
32
+ def generate_search_terms(
33
+ client: InferenceClient,
34
+ topic: str,
35
+ primary_keyword: str,
36
+ secondary_keyword: str,
37
+ brief: str,
38
+ ) -> List[str]:
39
+ user = (
40
+ f"Topic: {topic}\n"
41
+ f"Primary keyword: {primary_keyword}\n"
42
+ f"Secondary keyword: {secondary_keyword}\n"
43
+ f"Brief: {brief}\n\n"
44
+ f"Return {config.N_SEARCH_TERMS} search queries as a JSON array."
45
+ )
46
+ try:
47
+ raw = llm.chat(
48
+ client, config.MODEL_REASONING, _SYSTEM, user,
49
+ max_tokens=400, temperature=0.4,
50
+ )
51
+ terms = _parse_terms(raw)
52
+ if terms:
53
+ return terms[: config.N_SEARCH_TERMS]
54
+ except Exception:
55
+ pass
56
+ return _fallback_terms(topic, primary_keyword, secondary_keyword)
57
+
58
+
59
+ def _parse_terms(raw: str) -> List[str]:
60
+ """Extract a list of strings from a (possibly fenced) LLM response."""
61
+ match = re.search(r"\[.*\]", raw, re.DOTALL)
62
+ if match:
63
+ try:
64
+ data = json.loads(match.group(0))
65
+ return [str(t).strip() for t in data if str(t).strip()]
66
+ except Exception:
67
+ pass
68
+ # line-based fallback
69
+ lines = [re.sub(r'^[\s\-\*\d\.\)"]+', "", ln).strip().strip('"') for ln in raw.splitlines()]
70
+ return [ln for ln in lines if ln]
pipeline/searxng_client.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 2: query the in-container SearXNG JSON API and aggregate results by domain."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Dict, List
5
+ from urllib.parse import urlparse
6
+
7
+ import requests
8
+ import tldextract
9
+
10
+ from . import config
11
+
12
+
13
+ def registrable_domain(url: str) -> str:
14
+ """Return the registrable domain (e.g. 'blog.example.co.uk' -> 'example.co.uk')."""
15
+ ext = tldextract.extract(url)
16
+ if ext.domain and ext.suffix:
17
+ return f"{ext.domain}.{ext.suffix}".lower()
18
+ return (urlparse(url).netloc or url).lower()
19
+
20
+
21
+ def _search_one(term: str) -> List[dict]:
22
+ params = {"q": term, "format": "json", "safesearch": "1", "language": "en"}
23
+ try:
24
+ r = requests.get(
25
+ f"{config.SEARXNG_URL}/search",
26
+ params=params,
27
+ timeout=config.HTTP_TIMEOUT,
28
+ headers={"User-Agent": "BlogPostGenerator/1.0"},
29
+ )
30
+ r.raise_for_status()
31
+ return r.json().get("results", []) or []
32
+ except Exception:
33
+ return []
34
+
35
+
36
+ def search(terms: List[str], top_n: int = config.TOP_N) -> List[dict]:
37
+ """Run all queries, aggregate scores per domain, and return the top-N domains.
38
+
39
+ Each returned item: {domain, url, title, snippet, score}. One representative URL
40
+ (the highest-scoring) is kept per registrable domain so OpenPageRank ranks domains.
41
+ """
42
+ by_domain: Dict[str, dict] = {}
43
+ for term in terms:
44
+ for res in _search_one(term):
45
+ url = res.get("url")
46
+ if not url or not url.startswith("http"):
47
+ continue
48
+ dom = registrable_domain(url)
49
+ if not dom:
50
+ continue
51
+ score = float(res.get("score") or 0.0)
52
+ entry = by_domain.get(dom)
53
+ if entry is None:
54
+ by_domain[dom] = {
55
+ "domain": dom,
56
+ "url": url,
57
+ "title": res.get("title") or "",
58
+ "snippet": res.get("content") or "",
59
+ "score": score,
60
+ "_best": score, # highest single-result score seen for this domain
61
+ }
62
+ else:
63
+ entry["score"] += score
64
+ # keep the single best-scoring page as the domain's representative URL
65
+ if score > entry["_best"]:
66
+ entry["_best"] = score
67
+ entry["url"] = url
68
+ entry["title"] = res.get("title") or entry["title"]
69
+ entry["snippet"] = res.get("content") or entry["snippet"]
70
+
71
+ ranked = sorted(by_domain.values(), key=lambda x: x["score"], reverse=True)
72
+ for e in ranked:
73
+ e.pop("_best", None)
74
+ return ranked[:top_n]
pipeline/writer.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Step 5: write the blog post from the extracted source material.
2
+
3
+ Output is Markdown with:
4
+ # H1 title, ## H2 sections, normal paragraphs, and
5
+ [IMAGE: <scene description>] markers where illustrations belong.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import List
11
+
12
+ from huggingface_hub import InferenceClient
13
+
14
+ from . import config, llm
15
+
16
+ _SYSTEM = (
17
+ "You are an expert blog writer and SEO editor. Using ONLY the supplied source "
18
+ "material as factual grounding, write an original, engaging, well-structured blog "
19
+ "post. Do not copy sentences verbatim from the sources; synthesize in your own voice.\n\n"
20
+ "Requirements:\n"
21
+ "- Start with a single '# ' H1 title that includes the primary keyword.\n"
22
+ "- Use the primary keyword naturally in the first 100 words and in at least one '## ' heading.\n"
23
+ "- Weave the secondary keyword in naturally 1-3 times.\n"
24
+ "- Use '## ' subheadings to organize sections; write substantive paragraphs.\n"
25
+ "- Honor the brief's angle, audience, and tone.\n"
26
+ f"- Insert exactly {config.N_IMAGES} image markers of the form "
27
+ "'[IMAGE: a vivid visual scene to illustrate this section]' at natural points "
28
+ "(never two in a row, not in the title).\n"
29
+ "- End with a short conclusion.\n"
30
+ "Return Markdown only — no preamble, no code fences."
31
+ )
32
+
33
+
34
+ def write_post(
35
+ client: InferenceClient,
36
+ topic: str,
37
+ primary_keyword: str,
38
+ secondary_keyword: str,
39
+ brief: str,
40
+ sources: List[dict],
41
+ ) -> str:
42
+ source_block = _format_sources(sources)
43
+ user = (
44
+ f"Topic: {topic}\n"
45
+ f"Primary keyword: {primary_keyword}\n"
46
+ f"Secondary keyword: {secondary_keyword}\n"
47
+ f"Brief: {brief}\n\n"
48
+ f"SOURCE MATERIAL (ranked by domain authority):\n{source_block}\n\n"
49
+ "Write the full blog post in Markdown now."
50
+ )
51
+ md = llm.chat(
52
+ client,
53
+ config.MODEL_WRITER,
54
+ _SYSTEM,
55
+ user,
56
+ max_tokens=3500,
57
+ temperature=0.7,
58
+ fallback_model=config.MODEL_WRITER_FALLBACK,
59
+ )
60
+ md = _strip_fences(md)
61
+ return _ensure_image_markers(md)
62
+
63
+
64
+ def _format_sources(sources: List[dict]) -> str:
65
+ parts = []
66
+ for i, s in enumerate(sources, 1):
67
+ pr = s.get("page_rank")
68
+ pr_str = f"PR={pr}" if pr is not None else "PR=n/a"
69
+ parts.append(
70
+ f"[Source {i}] {s.get('title', '')} ({s.get('domain', '')}, {pr_str})\n"
71
+ f"URL: {s.get('url', '')}\n"
72
+ f"{s.get('text', '')}\n"
73
+ )
74
+ return "\n".join(parts)
75
+
76
+
77
+ def _strip_fences(md: str) -> str:
78
+ md = md.strip()
79
+ if md.startswith("```"):
80
+ md = re.sub(r"^```[a-zA-Z]*\n", "", md)
81
+ md = re.sub(r"\n```$", "", md)
82
+ return md.strip()
83
+
84
+
85
+ def _ensure_image_markers(md: str) -> str:
86
+ """Guarantee at least one image marker so the illustration step has work to do."""
87
+ if re.search(r"\[IMAGE:", md):
88
+ return md
89
+ # inject one marker after the first paragraph following the H1
90
+ lines = md.splitlines()
91
+ out, injected = [], False
92
+ for idx, line in enumerate(lines):
93
+ out.append(line)
94
+ if not injected and idx > 0 and line.strip() == "" and lines[idx - 1].strip() and not lines[idx - 1].startswith("#"):
95
+ out.append("[IMAGE: a compelling hero image illustrating the topic]")
96
+ out.append("")
97
+ injected = True
98
+ if not injected:
99
+ out.append("\n[IMAGE: a compelling hero image illustrating the topic]")
100
+ return "\n".join(out)
101
+
102
+
103
+ def parse_image_markers(md: str) -> List[str]:
104
+ """Return the scene descriptions from all [IMAGE: ...] markers, in order."""
105
+ return [m.strip() for m in re.findall(r"\[IMAGE:\s*(.+?)\]", md, re.DOTALL)]
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ gradio==4.44.1
2
+ huggingface_hub>=0.28.0
3
+ requests>=2.32.0
4
+ trafilatura>=1.12.0
5
+ python-docx>=1.1.2
6
+ Pillow>=10.4.0
7
+ PyYAML>=6.0.2
8
+ tldextract>=5.1.0
searxng/settings.yml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Minimal SearXNG override — inherits all engine defaults, changes only what we need.
2
+ # See https://docs.searxng.org/admin/settings/index.html
3
+ use_default_settings: true
4
+
5
+ general:
6
+ instance_name: "Blog Post Generator Search"
7
+ # keep it internal; no public UI needed
8
+ enable_metrics: false
9
+
10
+ server:
11
+ # replaced at container start if left as the placeholder
12
+ secret_key: "ultrasecretkey_change_me"
13
+ bind_address: "127.0.0.1"
14
+ port: 8080
15
+ # single trusted caller (our own app on localhost) — no bot limiter
16
+ limiter: false
17
+ public_instance: false
18
+ image_proxy: false
19
+
20
+ search:
21
+ # JSON is required for our client; keep html for manual debugging
22
+ formats:
23
+ - html
24
+ - json
25
+ # be resilient: don't fail the whole query if one engine errors
26
+ autocomplete: ""
27
+ default_lang: "en"
28
+
29
+ ui:
30
+ static_use_hash: true
31
+
32
+ # Redis is not available in this single-container setup; SearXNG runs without it.
33
+ redis:
34
+ url: false
start.sh ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Launch SearXNG (internal) then the Gradio app (public). SearXNG runs in the
3
+ # background bound to 127.0.0.1:8080; the app talks to it over localhost.
4
+ set -euo pipefail
5
+
6
+ SEARXNG_HOST="127.0.0.1"
7
+ SEARXNG_PORT="8080"
8
+
9
+ # A settings.yml secret_key must be set. If the shipped one is still the
10
+ # placeholder, generate one at runtime (settings dir is made writable in the image).
11
+ SETTINGS="${SEARXNG_SETTINGS_PATH:-/etc/searxng/settings.yml}"
12
+ if grep -q 'ultrasecretkey_change_me' "$SETTINGS" 2>/dev/null; then
13
+ KEY="$(python -c 'import secrets; print(secrets.token_hex(32))')"
14
+ # portable in-place edit (BusyBox sed supports -i)
15
+ sed -i "s/ultrasecretkey_change_me/${KEY}/" "$SETTINGS" || true
16
+ fi
17
+
18
+ echo "[start] launching SearXNG on ${SEARXNG_HOST}:${SEARXNG_PORT} ..."
19
+ python -m searx.webapp &
20
+ SEARXNG_PID=$!
21
+
22
+ # Wait for SearXNG to answer before starting the app (max ~30s).
23
+ for i in $(seq 1 30); do
24
+ if python - <<'PY' 2>/dev/null; then
25
+ import socket, sys
26
+ s = socket.socket()
27
+ s.settimeout(1)
28
+ try:
29
+ s.connect(("127.0.0.1", 8080))
30
+ sys.exit(0)
31
+ except Exception:
32
+ sys.exit(1)
33
+ finally:
34
+ s.close()
35
+ PY
36
+ echo "[start] SearXNG is up."
37
+ break
38
+ fi
39
+ if ! kill -0 "$SEARXNG_PID" 2>/dev/null; then
40
+ echo "[start] SearXNG process exited early; continuing (search will be degraded)."
41
+ break
42
+ fi
43
+ echo "[start] waiting for SearXNG ($i/30) ..."
44
+ sleep 1
45
+ done
46
+
47
+ echo "[start] launching Gradio app on 0.0.0.0:7860 ..."
48
+ exec python /app/app.py