Turn a screenshot, image(s), PDF, pasted text, or the current conversation into a self-contained, styled HTML blogpost — Gemini analyses the supplied content, then generates a complete HTML5 article (inline responsive CSS, optional MathJax) that expands on the concepts it found. The result is written to disk and opened in your browser.
One screenshot of an old book vignette goes in:
python main.py --analyze-only --screenshot-path vignette.pngIt writes a folder with the post and a copy of the image under blogposts/ next
to main.py, and prints (paths shortened here):
Saved image: …/blogposts/blogpost_20260825_195410/screenshot_20260825_195410.png
Saved HTML: …/blogposts/blogpost_20260825_195410/index.html
The full post is in examples/book-vignette/, exactly as
the tool wrote it. GitHub shows an .html file as source, so clone the repo and open
examples/book-vignette/index.html in a browser, or view it rendered through
raw.githack.
Gemini writes a different post on every run, so your output will differ.
Two phases, both Gemini:
- Analyse the inputs (text + images extracted from PDFs, raw images, or pasted text).
- Generate a self-contained blogpost into its own folder
blogposts/<name>/— anindex.htmlplus any referenced images — then open it in the browser.
pip install -r requirements.txtSet a Gemini API key — either export it or drop a .env next to main.py:
export GEMINI_API_KEY=... # or:
echo 'GEMINI_API_KEY=...' > .envOptional .env knobs:
| Variable | Purpose | Default |
|---|---|---|
GEMINI_API_KEY |
required — Gemini API key | — |
COMPETENT_GEMINI_MODELS, STRONG_GEMINI_MODELS |
comma-separated fallback model lists, tried after the preferred model | built-in fallbacks |
BROWSER_PATH |
browser to open the result | /usr/bin/firefox |
BLOGGEN_ENV |
explicit path to a .env file |
— |
The preferred model is gemini-3.6-flash; the env model lists (and a small
built-in list) act as fallbacks.
# From one or more images
python main.py --analyze-only --image-files a.png b.jpg
# From a PDF (extracts its text AND embedded images)
python main.py --analyze-only --text-files paper.pdf
# From a single screenshot (screenshot-specific prompt + hero image)
python main.py --analyze-only --screenshot-path shot.png
# From literal text — write it to a file first
printf '%s' "the text to write up" > /tmp/sb_text.txt
python main.py --analyze-only --raw-text-file /tmp/sb_text.txt
# Mix any of the above in one post
python main.py --analyze-only --text-files notes.pdf --image-files fig1.png --raw-text-file /tmp/sb_text.txtThe tool prints Saved HTML: <path> for the generated file.
Every publish writes blogposts/<name>/source.json and stamps the same record
into the HTML head as <meta name="bloggen:provenance"> (the sidecar is easy to
read; the meta tag survives the folder being moved). It records the working
directory, the git repo + branch + commit if there is one, the input files, the
host and the mode (gemini or agent).
So run bloggen from the project the post is about — that is the signal that makes "which posts came out of my mujoco training?" answerable later. When the origin is more specific than the cwd, or isn't a file at all, name it:
python publish.py --html post.html --source .state/status.html
python main.py --analyze-only --text-files paper.pdf --source https://arxiv.org/abs/…The index page turns each recorded source into a filter chip and a clickable tag on the card. Posts published before this existed simply carry no source; nothing is guessed for them.
build_index.py scans blogposts/ and writes a single static page,
blogposts/index.html, that links to every post:
python build_index.py # regenerate
python build_index.py --open # …and open itIt reads each post's <title>, headings, first paragraphs and full body text,
embeds the whole corpus inline and ranks queries client-side with BM25F —
four weighted fields (title 8× · headings 3× · description 2× · body 1×), prefix
expansion for partial words, accent folding (wohler finds Wöhler) and a
phrase bonus. Works straight off file://: no server, no network, no
dependencies beyond the standard library.
The page is a two-pane layout — a scrollable result list plus a live preview
iframe on wide screens, list-only below 1024 px — and never produces a
page-level scrollbar; only the inner panes scroll. Legacy loose
blogposts/blogpost_*.html files are indexed too, and ones that are
byte-identical to a <slug>/index.html are dropped as duplicates.
Re-run it after publishing; the page is regenerated from scratch each time.
python main.py --installregisters a desktop launcher (and the Claude Code skill). This needs thecli-tools-kitpackage; blogpost generation itself works without it via the CLI flags above.python main.py --install-skill/--uninstall-skillregister a Claude Codebloggenskill (~/.claude/skills/bloggen/SKILL.md).- Running with no flags opens an interactive tkinter content window if the
optional
_shared.guipackage is available; otherwise it points you to the CLI flags.
MIT — see LICENSE.