Repository navigation
Expand file tree
/
Copy pathmain.py
More file actions
1924 lines (1637 loc) · 84.4 KB
/
Copy pathmain.py
File metadata and controls
1924 lines (1637 loc) · 84.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import os
import subprocess
import sys
import re
import argparse
import json
from pathlib import Path
# ================= ADVERTISEMENT =================
if "--advertise" in sys.argv:
metadata = [{
"name": "BlogGen",
"capability": "summarize",
"domain": "html",
"category": "research",
"desktop_file": "bloggen.desktop",
"icon": "document-edit",
"desc": "Generate a styled, self-contained HTML blogpost from images, PDFs, or text",
"terminal": False,
"args": [],
"tags": ["CLI", "Icon"],
"skill_name": "bloggen", # surfaces a Skill checkbox in the installer GUI
}]
print(json.dumps(metadata))
sys.exit(0)
# Add root directory to path for shared module imports
# Two levels up: tool_dir -> category_dir -> root
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT_DIR = os.path.dirname(os.path.dirname(SCRIPT_DIR))
if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
# ================= CLAUDE SKILL (single source of truth) =================
# Edit SKILL_MD_TEMPLATE here, then run `python main.py --install-skill`.
# Never hand-edit the installed file — --install-skill overwrites it.
# The template carries {tool_dir} / {python} / {env_file} placeholders that
# render_skill_md() fills in from THIS checkout at install time, so the written
# SKILL.md names the paths of the machine it was installed on and no author's
# layout is baked into the source.
# Legacy skill dir names this tool has shipped under; pruned on (re)install so
# hosts that synced an older name don't keep a stale ~/.claude/skills entry.
_LEGACY_SKILL_NAMES = ["screenshot-blogpost"]
SKILL_DIR = Path.home() / ".claude" / "skills" / "bloggen"
SKILL_FILE = SKILL_DIR / "SKILL.md"
TOOL_DIR = Path(__file__).resolve().parent
SKILL_MD_TEMPLATE = '''---
name: bloggen
description: Turn a screenshot, image(s), PDF, pasted text, or the current conversation into a self-contained, styled HTML blogpost saved to disk and opened in the browser. You may author the HTML yourself (default) or delegate writing to Gemini. Use when the user wants content written up as a shareable HTML article — "make a blogpost from this screenshot/image/PDF", "turn this into an HTML write-up", "write up what's in this screenshot", "turn our conversation/this discussion into a blogpost", "Blogpost aus diesem Screenshot/Bild/PDF", "mach aus unserem Gespräch einen Artikel". NOT for plain OCR / text or LaTeX extraction (use transcribe-image) and NOT for the daily digest (use digest).
---
# BlogGen — image/PDF/text/conversation -> styled HTML blogpost
Produces a self-contained HTML5 blogpost (inline CSS, responsive layout,
optional MathJax) in its own folder under the tool's `blogposts/` directory —
an `index.html` plus every image it references — and opens it in Firefox.
The writing can be done two ways. **Pick the mode before you run anything.**
## Mode B — you author it (DEFAULT — prefer this)
**Do both phases yourself:** analyse the source, then write the HTML, then hand
it to `publish.py` for the folder/image/browser mechanics. No Gemini involved.
This is the default because you almost always already hold the content, and
delegating then means paying a second model to paraphrase your own summary —
which adds wording drift without adding information. Choose Mode B when:
- **any claim must be traceable** — repo state, measured numbers, quoted
results, anything a reviewer/professor/colleague will check;
- the source **is the conversation** (Gemini would only ever see your lossy
summary of it, never the real thread);
- you already read the files/images being written about;
- a specific structure, house style, or language is required;
- the user asked for *your* writing, or wants to iterate on the text with you.
```bash
PUB={tool_dir}/publish.py
PY={python}
# 1. Start from the house template so styling stays consistent across modes:
# {tool_dir}/assets/template.html
# Read it, fill TITLE / standfirst / <article> body, drop the MathJax
# <script> pair if there is no maths. Write the result anywhere, e.g.
# the scratchpad.
# 2. Reference images by BASENAME ONLY: <img src="fig1.png">
# 2b. Write in the language of the source: a German screenshot/PDF/conversation
# gets a German post. Set <html lang="de"> to match - the index reads it.
# 3. Publish (creates blogposts/<slug>_<timestamp>/, copies images, opens Firefox):
"$PY" "$PUB" --html /path/post.html --image-files /abs/fig1.png /abs/fig2.png
```
`publish.py` flags: `--name <slug>` (default: the `<title>`), `--no-open`,
`--source <origin ...>`.
It warns if an `<img src>` is a path rather than a basename, references a file
you did not pass, or if a supplied image is never referenced — fix those, they
mean a broken image in the published post.
## Always record where the material came from
Every post gets a `source.json` sidecar (and a matching `<meta>` tag) naming
the working directory, the git repo + commit, and the inputs. That is what
makes "list the posts about my mujoco training" answerable later, so:
- **Run publish.py from the directory the post is about** — the repo the work
happened in, not the scratchpad. The cwd is captured automatically.
- Add `--source` when the origin is more specific than the cwd or is not a
file at all: `--source .state/status.html`, `--source https://…`,
`--source "conversation: sim-to-sim transfer gap"`. Repeatable.
- Both apply to Mode A too (`main.py --source …`).
## Mode A — delegate to Gemini
Two-phase Gemini run: it *analyzes* the supplied content (text + images), then
*generates* the whole post — prose, CSS, layout — expanding on what it found.
Costs no agent tokens and needs no reading on your part.
Use when the source is an **artifact you have not ingested** (a long PDF, a
screenshot you are not otherwise reading), when per-claim fidelity does not
matter much, or when the user explicitly wants the tool's own voice/flair.
**Do not use Mode A to write up work you already know** — that is the case
Mode B exists for.
### Invocation — use absolute paths, NOT the alias/desktop entry
Run the tool's headless `--analyze-only` content mode directly. Re-define these
in **every** Bash call (shell state does not persist between calls). The
`BLOGGEN_ENV` export points the tool at the `.env` holding `GEMINI_API_KEY`
(plus any shared model lists) — the tool parses it with python-dotenv, which
handles the multi-line model lists that a shell `source` chokes on. Set it only
to override the default; without it the tool reads the `.env` beside `main.py`:
```bash
PY={python}
SB={tool_dir}/main.py
export BLOGGEN_ENV={env_file}
```
### Mode A commands
```bash
# From one or more images
"$PY" "$SB" --analyze-only --image-files /path/a.png /path/b.jpg
# From a PDF (extracts its text AND embedded images)
"$PY" "$SB" --analyze-only --text-files /path/paper.pdf
# From a single screenshot/image (screenshot-specific prompt + hero image)
"$PY" "$SB" --analyze-only --screenshot-path /path/shot.png
# From literal/pasted text, or from the current conversation — write it to a
# file first (summarise the discussion into it), then pass it
printf '%s' "the text to write up" > /tmp/sb_text.txt
"$PY" "$SB" --analyze-only --raw-text-file /tmp/sb_text.txt
# Mix any of the above in one post
"$PY" "$SB" --analyze-only --text-files notes.pdf --image-files fig1.png --raw-text-file /tmp/sb_text.txt
```
## Mode C — hybrid (rare)
Run Mode A, then **read the generated `index.html` and correct it** against your
own knowledge of the source before showing the user. Worth it only when you want
Gemini's design work but need your accuracy. Say plainly in your reply that you
patched it.
## Output (both modes)
- Saves a per-post folder `blogposts/<name>/` containing `index.html`
plus its images, under `.../AutomatedAlchemy/bloggen/blogposts/`, and
prints the path as `Saved HTML: <path>` — read that line back to the user.
- Auto-opens the result in Firefox.
- Non-interactive runs (Claude's Bash tool) skip the end-of-run countdown and
return promptly.
## Reporting to the user
State which mode you used. If Gemini wrote the prose (Mode A), **say so** — the
user needs to know the wording is not yours before forwarding it to anyone.
## Notes
- Mode B needs no API key. Mode A needs `GEMINI_API_KEY` in
`{env_file}`, or in any `.env` named by `BLOGGEN_ENV`.
- Prefers `gemini-3.6-flash`, then falls back through the shared `.env` model lists.
- `--text-files` accepts PDFs (text + images extracted) and plain-text files.
- `--image-files` accepts ordinary image files; up to 5 images per PDF are pulled in.
- This tool *writes about* the content (generation). For straight OCR / LaTeX
extraction of a page, use the `transcribe-image` skill instead.
'''
def _tool_python() -> str:
"""The interpreter the skill's example commands should use.
Prefer a venv the tool owns (that is where its dependencies are installed by
the cli-tools-kit installer), otherwise the interpreter running this script.
"""
for candidate in (TOOL_DIR / ".venv" / "bin" / "python3",
TOOL_DIR / ".venv" / "bin" / "python",
TOOL_DIR / ".venv" / "Scripts" / "python.exe"):
if candidate.exists():
return str(candidate)
return sys.executable
def render_skill_md() -> str:
"""Fill the SKILL.md template with this checkout's paths."""
return SKILL_MD_TEMPLATE.format(
tool_dir=TOOL_DIR,
python=_tool_python(),
env_file=TOOL_DIR / ".env",
)
def _prune_legacy_skill_dirs() -> None:
"""Drop skill dirs from previous names of this tool (e.g. screenshot-blogpost)."""
skills_root = SKILL_DIR.parent
for legacy in _LEGACY_SKILL_NAMES:
legacy_dir = skills_root / legacy
if legacy_dir == SKILL_DIR or not legacy_dir.exists():
continue
legacy_file = legacy_dir / "SKILL.md"
try:
if legacy_file.exists():
legacy_file.unlink()
legacy_dir.rmdir() # only if now empty
print(f" Pruned legacy skill dir: {legacy_dir}")
except OSError:
pass
def _install_skill() -> None:
"""Write (or refresh) ~/.claude/skills/bloggen/SKILL.md from the inline source."""
_prune_legacy_skill_dirs()
SKILL_DIR.mkdir(parents=True, exist_ok=True)
content = render_skill_md()
pre_existed = SKILL_FILE.exists()
if pre_existed and SKILL_FILE.read_text(encoding="utf-8") == content:
print(f" - Skill already up-to-date: {SKILL_FILE}")
return
SKILL_FILE.write_text(content, encoding="utf-8")
verb = "Refreshed" if pre_existed else "Installed"
print(f" Skill {verb}: {SKILL_FILE}")
print(" Claude Code picks this up live - no restart needed.")
def _uninstall_skill() -> None:
"""Remove ~/.claude/skills/bloggen/SKILL.md (and the empty dir)."""
_prune_legacy_skill_dirs()
if SKILL_FILE.exists():
SKILL_FILE.unlink()
print(f" Removed {SKILL_FILE}")
else:
print(f" No skill file at {SKILL_FILE}")
try:
SKILL_DIR.rmdir() # only if empty
except OSError:
pass
if "--install-skill" in sys.argv:
_install_skill()
sys.exit(0)
if "--uninstall-skill" in sys.argv:
_uninstall_skill()
sys.exit(0)
# ================= INSTALL / REMOVE (before heavy imports) =================
# Canonical import is the cli_tools_kit pip package; fall back to the in-tree
# shim for environments without it (subtree exports, etc.).
if "--install" in sys.argv or "--remove" in sys.argv:
try:
from cli_tools_kit import ToolInstaller, ToolMetadata
except ImportError:
try:
from _shared.tool_installer import ToolInstaller, ToolMetadata
except ImportError:
print(
"--install/--remove need the cli-tools-kit package "
"(pip install cli-tools-kit). Blogpost generation itself works "
"without it via the CLI flags — see the README."
)
sys.exit(1)
_installer = ToolInstaller(
script_path=__file__,
metadata=ToolMetadata(
name="BlogGen",
desktop_file="bloggen.desktop",
icon="document-edit",
desc="Generate a styled, self-contained HTML blogpost from images, PDFs, or text",
categories="Utility;Office;",
),
)
if "--install" in sys.argv:
_installer.install()
_install_skill() # best-effort so direct CLI use is one-shot
else:
_installer.remove()
_uninstall_skill()
sys.exit(0)
import shutil
import webbrowser
import select
import termios
import tty
import traceback
import tempfile
from datetime import datetime
from dotenv import load_dotenv, find_dotenv
from PIL import Image
import google.generativeai as genai
from google.generativeai.types import HarmCategory, HarmBlockThreshold
from termcolor import colored
# Optional PDF support - graceful fallback if not installed
try:
import PyPDF2
HAS_PYPDF2 = True
except ImportError:
HAS_PYPDF2 = False
# ================= CONFIGURATION =================
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
# Load a .env without baking in any one checkout layout: an explicit override
# first, then the tool's own directory, then a walk up the tree (so a .env at a
# parent/repo root is still found). If none exist we fall back to the process
# environment (e.g. GEMINI_API_KEY exported by the shell), so the tool stays
# usable both standalone and inside a larger workspace.
_env_override = os.environ.get("BLOGGEN_ENV") or os.environ.get("SCREENSHOT_BLOGPOST_ENV")
_local_env = os.path.join(SCRIPT_DIR, ".env")
_env_path = _env_override or (_local_env if os.path.exists(_local_env) else find_dotenv(usecwd=False))
if _env_path:
load_dotenv(_env_path)
# API Configuration
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
# Model this tool prefers, kept ahead of the shared .env lists so the blogpost
# generator uses it first; the env-configured models remain as fallbacks (and
# the generation loop already skips a MODEL NOT FOUND id gracefully).
# Overridable per run with BLOGGEN_MODEL=<model id>.
PREFERRED_MODEL = os.getenv("BLOGGEN_MODEL", "gemini-3.6-flash")
def get_candidate_models():
"""Unique, ordered model list: PREFERRED_MODEL first, then any models from the
shared .env vars, then a hardcoded fallback when no env vars are set."""
models = [PREFERRED_MODEL]
# Priority order for fallback: specific var -> list var -> strong var
for var in ["COMPETENT_GEMINI_MODELS", "STRONG_GEMINI_MODELS"]:
val = os.getenv(var)
if val:
for m in val.split(","):
m = m.strip()
if m and m not in models:
models.append(m)
if models == [PREFERRED_MODEL]:
models += ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-1.5-flash"]
return models
GEMINI_CANDIDATE_MODELS = get_candidate_models()
BROWSER_PATH = os.getenv("BROWSER_PATH", "/usr/bin/firefox")
# Per-invocation tempdir avoids the symlink-race on a fixed /tmp/<name>.png path
# (a local attacker on a shared host could pre-symlink it at any writable target).
import tempfile as _tempfile
TEMP_FILENAME = os.path.join(
_tempfile.mkdtemp(prefix="bloggen_"), "screenshot.png"
)
BLOGPOST_DIR = os.path.join(SCRIPT_DIR, "blogposts")
# Folder-per-post layout: every run gets its OWN subdirectory under blogposts/,
# holding index.html plus all images it references. This keeps the HTML's bare
# <img src="foo.png"> relative refs working while isolating each post. Follow-up
# revisions within one session re-reference the original post's images by name,
# so all revisions of a session share this single directory (established at the
# first save, reused thereafter). See _session_post_dir().
_SESSION_POST_DIR = None
# Provenance for this invocation — where the material came from. Filled in by
# main() from the CLI args, read by both save paths. See provenance.py.
if SCRIPT_DIR not in sys.path:
sys.path.insert(0, SCRIPT_DIR)
import provenance # noqa: E402 — sibling module, needs SCRIPT_DIR on sys.path
_SOURCE_INPUTS = [] # files this run actually read
_SOURCE_DECLARED = [] # --source values, if the caller named an origin
def _record_provenance(post_dir, html_content):
"""Write the sidecar, stamp the HTML, report it. Returns the new HTML."""
record = provenance.collect("gemini", inputs=_SOURCE_INPUTS,
sources=_SOURCE_DECLARED)
provenance.write(post_dir, record)
print(colored(f"Source: {provenance.describe(record)}", "cyan"))
return provenance.stamp(html_content, record)
def _session_post_dir(base_name):
"""Return this session's post folder under blogposts/, creating it once.
The first save in a session wins the folder name; follow-up saves reuse the
same folder so their images stay co-located. `base_name` is the descriptive
stem (no extension) the old flat filename would have used, e.g.
"blogpost_20260714_153904" or "blogpost_Uebung_1_pdf_20260120_112316".
"""
global _SESSION_POST_DIR
if _SESSION_POST_DIR is None:
safe = re.sub(r'[^\w\-]', '_', base_name).strip('_') or "blogpost"
_SESSION_POST_DIR = os.path.join(BLOGPOST_DIR, safe)
os.makedirs(_SESSION_POST_DIR, exist_ok=True)
return _SESSION_POST_DIR
# ================= UTILS & INSTALLATION =================
def parse_arguments():
parser = argparse.ArgumentParser(description="Generate HTML blogpost from screenshot using AI")
parser.add_argument("--install", action="store_true", help="Install desktop shortcut")
parser.add_argument("--remove", action="store_true", help="Remove desktop shortcut")
parser.add_argument("--analyze-only", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("--screenshot-path", type=str, help=argparse.SUPPRESS) # Parent passes its per-invocation temp screenshot path to the child
parser.add_argument("--screenshot-is-temp", action="store_true", help=argparse.SUPPRESS) # Set only by the parent: the child may delete that temp file
parser.add_argument("--text-files", type=str, nargs='*', help=argparse.SUPPRESS) # Paths to text/PDF files
parser.add_argument("--image-files", type=str, nargs='*', help=argparse.SUPPRESS) # Paths to image files
parser.add_argument("--raw-text-file", type=str, help=argparse.SUPPRESS) # Path to temp file with pasted text
parser.add_argument("--content-dir", type=str, help=argparse.SUPPRESS) # Directory with extracted PDF images
parser.add_argument("--source", type=str, nargs='*', default=[], metavar="ORIGIN",
help="Where the material came from: a path, a repo, a URL, or a "
"topic. The working directory is recorded either way.")
return parser.parse_args()
def launch_terminal_process():
python_exec = sys.executable
script_path = os.path.abspath(__file__)
# Pass the parent's per-invocation temp path explicitly: the child is a fresh
# Python process and would otherwise call mkdtemp() again, landing on a
# different dir and failing to find the screenshot the parent just saved.
cmd = ["konsole", "-e", python_exec, script_path,
"--analyze-only", "--screenshot-path", TEMP_FILENAME, "--screenshot-is-temp"]
subprocess.Popen(cmd)
def auto_close_timer(seconds=10):
"""Counts down if success. If ESC pressed, stays open."""
# Non-interactive (agent/headless) stdin has no TTY to read keys from and
# termios.tcgetattr would raise — nothing to wait on, so return promptly.
if not sys.stdin.isatty():
return
print(colored(f"\n[SUCCESS] Closing in {seconds} seconds. Press ESC to keep open.", "white"))
old_settings = termios.tcgetattr(sys.stdin)
try:
tty.setcbreak(sys.stdin.fileno())
for i in range(seconds, 0, -1):
sys.stdout.write(f"\rClosing in {i}s... ")
sys.stdout.flush()
if select.select([sys.stdin], [], [], 1)[0]:
key = sys.stdin.read(1)
if key == '\x1b': # ESC
sys.stdout.write("\nAuto-close CANCELLED. Window will stay open.\n")
input("\nPress Enter to close manually...")
return
except Exception:
pass
finally:
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
def manual_hold_on_crash():
print(colored("\n-------------------------------------------", "red", attrs=["bold"]))
print(colored(" PROCESS FAILED. WINDOW HELD OPEN. ", "red", attrs=["bold"]))
print(colored("-------------------------------------------", "red", attrs=["bold"]))
try:
input(colored("Press Enter to close terminal...", "white", attrs=["bold"]))
except:
pass
def get_cursor_position():
if not sys.stdin.isatty():
return 1, 1
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(sys.stdin)
try:
tty.setcbreak(fd)
sys.stdout.write("\033[6n")
sys.stdout.flush()
# Read at the raw fd level (os.read) rather than through the buffered
# sys.stdin text stream. Mixing the two loses bytes: sys.stdin.read()
# may slurp trailing bytes into Python's internal buffer where a later
# os.read()/select() can't see them.
resp = b""
while True:
resp += os.read(fd, 1)
if resp.endswith(b'R'):
break
m = re.match(rb'.*?\[(\d+);(\d+)R', resp)
if m:
return int(m.group(1)), int(m.group(2))
except Exception:
pass
finally:
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
return 1, 1
def read_input_event():
# Read at the raw fd level (os.read) rather than through the buffered
# sys.stdin text stream. sys.stdin.read(1) can slurp a whole chunk into
# Python's internal buffer and hand back one char; a subsequent select()
# on the fd then reports "nothing available" (the bytes are hidden in the
# buffer, not the OS), so a multi-byte escape sequence fragments and stray
# coordinate digits leak out as phantom "1/2/3" hotkey presses. os.read +
# fd-level select keeps select() honest.
fd = sys.stdin.fileno()
ch = os.read(fd, 1)
if ch != b'\x1b':
return ch.decode('utf-8', 'ignore')
seq = ch
# We saw ESC — assemble the full escape sequence. Under high-frequency mouse
# motion reporting (mode 1003) the bytes of a sequence can dribble in across
# scheduler gaps, so once we know it's a CSI/SS3 sequence we keep reading
# until its terminating byte instead of bailing on a short per-byte timeout.
if not select.select([fd], [], [], 0.2)[0]:
return '\x1b' # lone ESC (Escape key)
seq += os.read(fd, 1)
if seq[-1:] == b'[':
# CSI sequence: parameter/intermediate bytes (0x20-0x3F) then a final
# byte in 0x40-0x7E ('@'..'~'). Covers arrows (A/B/C/D), SGR mouse
# (M/m) and cursor-position reports (R).
while True:
if not select.select([fd], [], [], 0.5)[0]:
break
c = os.read(fd, 1)
seq += c
if 0x40 <= c[0] <= 0x7e:
break
elif seq[-1:] == b'O':
# SS3 sequence (application cursor keys: \x1bOA etc.) — one more byte.
if select.select([fd], [], [], 0.5)[0]:
seq += os.read(fd, 1)
return seq.decode('utf-8', 'ignore')
def parse_sgr_mouse(seq):
# SGR mouse report format is "\x1b[<btn;col;row" then a single terminator
# 'M' (press/motion) or 'm' (release) — no semicolon before the terminator.
m = re.match(r'^\x1b\[<(\d+);(\d+);(\d+)([Mm])$', seq)
if m:
pb = int(m.group(1))
px = int(m.group(2))
py = int(m.group(3))
is_press = m.group(4) == 'M'
return pb, px, py, is_press
return None
def flush_stdin():
if not sys.stdin.isatty():
return
try:
termios.tcflush(sys.stdin, termios.TCIFLUSH)
except:
pass
def draw_menu(R_start, active_index):
sys.stdout.write(f"\033[{R_start};1H")
border_color = "cyan"
title_color = "white"
b1_text = " 1. Enter Prompt "
b2_text = " 2. Take Screenshot "
b3_text = " 3. Exit "
if active_index == 0:
b1_disp = colored(b1_text, "white", "on_blue", attrs=["bold"])
else:
b1_disp = colored(b1_text, "dark_grey")
if active_index == 1:
b2_disp = colored(b2_text, "white", "on_blue", attrs=["bold"])
else:
b2_disp = colored(b2_text, "dark_grey")
if active_index == 2:
b3_disp = colored(b3_text, "white", "on_blue", attrs=["bold"])
else:
b3_disp = colored(b3_text, "dark_grey")
# Each button cell is 25 columns wide, so every horizontal rule must split
# its 77 inner columns as 25/25/25 (with two connectors) for the ┬/┴ joints
# to line up under the │ dividers in the button row. (They were 26/26/23,
# which drifted every connector one column to the right.)
seg = "─" * 25
top_row = "┌" + "─" * 77 + "┐"
mid_row = "├" + seg + "┬" + seg + "┬" + seg + "┤"
bot_row = "└" + seg + "┴" + seg + "┴" + seg + "┘"
sys.stdout.write(colored(top_row + "\n", border_color))
sys.stdout.write(colored("│", border_color) + colored("CHOOSE AN ACTION".center(77), title_color, attrs=["bold"]) + colored("│\n", border_color))
sys.stdout.write(colored(mid_row + "\n", border_color))
sys.stdout.write(colored("│", border_color) + b1_disp + colored("│", border_color) + b2_disp + colored("│", border_color) + b3_disp + colored("│\n", border_color))
sys.stdout.write(colored(bot_row + "\n", border_color))
sys.stdout.flush()
def save_and_open_followup_blogpost(html_content, combined_name):
# A follow-up is a revised version of the current session's post: it lives in
# the SAME folder (already created by the first save, so this reuses it) and
# overwrites index.html. Its images were copied in by the original save.
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
base_name = re.sub(r'[^\w\-]', '_', combined_name)[:30]
post_dir = _session_post_dir(f"blogpost_{base_name}_{timestamp}")
html_path = os.path.join(post_dir, "index.html")
with open(html_path, "w", encoding="utf-8") as f:
f.write(html_content)
print(colored(f"Saved follow-up HTML: {html_path}", "cyan"))
print(colored(f"\nOpening in browser...", "blue"))
try:
webbrowser.register('firefox_custom', None, webbrowser.BackgroundBrowser(BROWSER_PATH))
browser = webbrowser.get('firefox_custom')
except:
browser = webbrowser.get()
browser.open(f"file://{html_path}")
return html_path
def generate_followup_blogpost(chat, prompt_message):
history = list(chat.history)
for model_idx, model_name in enumerate(GEMINI_CANDIDATE_MODELS):
try:
print(colored(f"\n[TRYING MODEL: {model_name}] for follow-up", "white", attrs=["bold"]))
model = genai.GenerativeModel(model_name)
new_chat = model.start_chat(history=history)
print(colored("\n--- Follow-up HTML Generation ---", "green", attrs=["bold"]))
response = new_chat.send_message(
prompt_message,
stream=True,
safety_settings={HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_NONE}
)
html_response = stream_response(response, "green")
print(colored("\n===============================================", "green", attrs=["bold"]))
if not html_response.strip():
raise ValueError("Empty HTML response")
html_content = extract_html(html_response)
if html_content:
return html_content, new_chat
else:
print(colored("\nWarning: Could not extract HTML. Trying next model...", "yellow"))
continue
except Exception as e:
error_msg = str(e)
if is_transient_error(error_msg) and model_idx < len(GEMINI_CANDIDATE_MODELS) - 1:
print(colored(f"\n[!] TRANSIENT ERROR: {error_msg[:60]}... Trying next model...", "yellow"))
continue
elif "404" in error_msg or "not found" in error_msg.lower():
print(colored(f"\n[!] MODEL NOT FOUND: {model_name}. Skipping...", "yellow"))
continue
elif model_idx < len(GEMINI_CANDIDATE_MODELS) - 1:
print(colored(f"\n[!] ERROR: {error_msg[:60]}... Trying next model...", "yellow"))
continue
else:
raise
raise RuntimeError("All models failed during follow-up HTML generation.")
def handle_action(action_idx, chat, last_image_filename, combined_name, R_start, old_settings):
sys.stdout.write("\033[?1003l\033[?1006l\033[?25h\n")
sys.stdout.flush()
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
flush_stdin()
if action_idx == 2:
print(colored("Exiting. Goodbye!", "cyan"))
return True, chat
if action_idx == 0:
print(colored("\n--- ENTER PROMPT ---", "cyan", attrs=["bold"]))
try:
user_prompt = input("Enter your prompt: ").strip()
except (KeyboardInterrupt, EOFError):
print(colored("\nPrompt cancelled.", "yellow"))
return False, chat
if not user_prompt:
print(colored("Empty prompt. Continuing...", "yellow"))
return False, chat
prompt_message = f"""USER REQUEST:
{user_prompt}
YOUR TASK:
1. Plan your approach (think out loud): What changes/additions/updates are needed based on the user request?
2. Generate another complete, updated HTML blogpost from scratch. Do not just output fragments - write the entire self-contained HTML5 document.
3. Include the original/existing images (if any) using their filenames.
4. Output the complete HTML wrapped in ```html ... ```."""
try:
html_content, new_chat = generate_followup_blogpost(chat, prompt_message)
if html_content:
save_and_open_followup_blogpost(html_content, combined_name)
chat = new_chat
except Exception as e:
print(colored(f"\nError generating blogpost: {e}", "red"))
traceback.print_exc()
elif action_idx == 1:
print(colored("\n--- TAKE SCREENSHOT ---", "cyan", attrs=["bold"]))
print(colored("Please select a region for the new screenshot...", "yellow"))
screenshot_taken = take_screenshot()
if not screenshot_taken:
print(colored("Screenshot cancelled or failed.", "yellow"))
return False, chat
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
new_image_filename = f"screenshot_{timestamp}.png"
# Land the new screenshot in this session's post folder so the follow-up
# HTML's <img src="screenshot_...png"> resolves alongside index.html.
new_img_path = os.path.join(_session_post_dir(combined_name), new_image_filename)
try:
shutil.copy(TEMP_FILENAME, new_img_path)
print(colored(f"Saved new image: {new_img_path}", "cyan"))
except Exception as e:
print(colored(f"Failed to copy screenshot: {e}", "red"))
return False, chat
finally:
cleanup(TEMP_FILENAME)
prompt_text = f"""Here is a new screenshot.
YOUR TASK:
1. Observe the new image and analyze its content.
2. Plan your approach (think out loud): How will you integrate this new screenshot/information into the blogpost?
3. Generate another complete, updated HTML blogpost from scratch. Make sure to present both the original content and this new screenshot/information.
4. Use the new screenshot filename: {new_image_filename} (include it as <img src="{new_image_filename}">).
5. Output the complete HTML wrapped in ```html ... ```."""
try:
img = Image.open(new_img_path)
prompt_message = [prompt_text, img]
html_content, new_chat = generate_followup_blogpost(chat, prompt_message)
if html_content:
save_and_open_followup_blogpost(html_content, combined_name)
chat = new_chat
except Exception as e:
print(colored(f"\nError generating blogpost: {e}", "red"))
traceback.print_exc()
return False, chat
def run_interactive_loop(chat, last_image_filename, combined_name):
if not sys.stdin.isatty():
return
print(colored("\nTerminal kept open. Keyboard & stylus control enabled.", "green", attrs=["bold"]))
R_start, _ = get_cursor_position()
print("\n\n\n\n")
R_start, _ = get_cursor_position()
R_start = max(1, R_start - 5)
active_index = 0
draw_menu(R_start, active_index)
sys.stdout.write("\033[?1003h\033[?1006h\033[?25l")
sys.stdout.flush()
old_settings = termios.tcgetattr(sys.stdin)
try:
tty.setcbreak(sys.stdin.fileno())
while True:
if select.select([sys.stdin], [], [], 0.1)[0]:
event = read_input_event()
if not event:
continue
if event == '\r' or event == '\n':
action_idx = active_index
break_loop, chat = handle_action(action_idx, chat, last_image_filename, combined_name, R_start, old_settings)
if break_loop:
break
R_start, _ = get_cursor_position()
print("\n\n\n\n")
R_start, _ = get_cursor_position()
R_start = max(1, R_start - 5)
# handle_action() restored the terminal to cooked/echo mode
# (old_settings); re-enter cbreak before re-enabling mouse
# reporting, else the incoming mouse escape codes get echoed
# to the screen as visible garbage.
tty.setcbreak(sys.stdin.fileno())
sys.stdout.write("\033[?1003h\033[?1006h\033[?25l")
sys.stdout.flush()
draw_menu(R_start, active_index)
# Arrow keys: normal mode (\x1b[A/B/C/D) AND
# application cursor mode (\x1bOA/B/C/D) — Konsole switches
# to application mode when SGR mouse reporting is active.
elif event in ('\x1b[A', '\x1b[D', '\x1bOA', '\x1bOD'):
active_index = (active_index - 1) % 3
draw_menu(R_start, active_index)
elif event in ('\x1b[B', '\x1b[C', '\x1bOB', '\x1bOC'):
active_index = (active_index + 1) % 3
draw_menu(R_start, active_index)
# Number key shortcuts: 1 / 2 / 3
elif event in ('1', '2', '3'):
action_idx = int(event) - 1
active_index = action_idx
draw_menu(R_start, active_index)
break_loop, chat = handle_action(action_idx, chat, last_image_filename, combined_name, R_start, old_settings)
if break_loop:
break
R_start, _ = get_cursor_position()
print("\n\n\n\n")
R_start, _ = get_cursor_position()
R_start = max(1, R_start - 5)
# handle_action() restored the terminal to cooked/echo mode
# (old_settings); re-enter cbreak before re-enabling mouse
# reporting, else the incoming mouse escape codes get echoed
# to the screen as visible garbage.
tty.setcbreak(sys.stdin.fileno())
sys.stdout.write("\033[?1003h\033[?1006h\033[?25l")
sys.stdout.flush()
draw_menu(R_start, active_index)
elif event.startswith('\x1b[<'):
parsed = parse_sgr_mouse(event)
if parsed:
pb, px, py, is_press = parsed
# Button row is the 4th line of the 5-line menu box
if py == R_start + 3:
clicked_idx = -1
if 2 <= px <= 26:
clicked_idx = 0
elif 28 <= px <= 52:
clicked_idx = 1
elif 54 <= px <= 78:
clicked_idx = 2
if clicked_idx != -1:
if clicked_idx != active_index:
active_index = clicked_idx
draw_menu(R_start, active_index)
# Fire on press (M) so stylus/touch works even
# without a release event; ignore drag buttons.
if is_press and pb in (0, 1, 2, 64, 65):
break_loop, chat = handle_action(clicked_idx, chat, last_image_filename, combined_name, R_start, old_settings)
if break_loop:
break
R_start, _ = get_cursor_position()
print("\n\n\n\n")
R_start, _ = get_cursor_position()
R_start = max(1, R_start - 5)
# Re-enter cbreak (handle_action left the
# terminal in cooked/echo mode) before
# re-enabling mouse reporting, else mouse
# codes echo to the screen as garbage.
tty.setcbreak(sys.stdin.fileno())
sys.stdout.write("\033[?1003h\033[?1006h\033[?25l")
sys.stdout.flush()
draw_menu(R_start, active_index)
finally:
sys.stdout.write("\033[?1003l\033[?1006l\033[?25h")
sys.stdout.flush()
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
# ================= FILE INPUT FALLBACK =================
def open_content_dialog():
"""Open the interactive data-retrieval window for blogpost generation.
The tkinter window lives in the optional ``_shared.gui`` package (part of the
author's tools monorepo / cli-tools-kit). In a standalone checkout it won't be
importable — that's fine: the CLI flags (``--screenshot-path`` /
``--image-files`` / ``--text-files`` / ``--raw-text-file``) cover every
headless path, so we just tell the user to use those instead.
"""
try:
from _shared.gui import DataRetrievalWindow, DataRetrievalConfig
except ImportError:
print(colored(
"Interactive content window unavailable (optional _shared.gui package "
"not installed). Use the CLI flags instead — e.g.\n"
" main.py --analyze-only --image-files shot.png\n"
" main.py --analyze-only --text-files paper.pdf\n"
" main.py --analyze-only --raw-text-file notes.txt\n"
"See the README for all options.", "yellow"))
return None
config = DataRetrievalConfig(
title="Blogpost Content Input",
confirm_button_text="Generate Blogpost",
enable_text_input=True,
enable_images=True
)
return DataRetrievalWindow(config).run() # Returns dict {'texts': list, 'files': list, 'images': list} or None
def extract_text_from_pdf(pdf_path):
"""Extract raw text from a PDF file."""
if not HAS_PYPDF2:
print(colored("PyPDF2 not installed. Install with: pip install PyPDF2", "red"))
return None
try:
text_parts = []
with open(pdf_path, 'rb') as f:
reader = PyPDF2.PdfReader(f)
print(colored(f"PDF has {len(reader.pages)} pages", "white"))
for i, page in enumerate(reader.pages):
page_text = page.extract_text()
if page_text:
text_parts.append(f"--- Page {i+1} ---\n{page_text}")
full_text = "\n\n".join(text_parts)
print(colored(f"Extracted {len(full_text)} characters from PDF", "green"))
return full_text
except Exception as e:
print(colored(f"Error reading PDF text: {e}", "red"))
return None
def extract_images_from_pdf(pdf_path, output_dir):
"""
Extract embedded images from a PDF file.
Returns list of image paths.
"""
if not HAS_PYPDF2:
print(colored("PyPDF2 not installed. Install with: pip install PyPDF2", "red"))
return []
try:
os.makedirs(output_dir, exist_ok=True)
print(colored(f"Extracting embedded images from PDF...", "cyan"))
image_paths = []
base_name = os.path.splitext(os.path.basename(pdf_path))[0]
base_name = re.sub(r'[^\w\-]', '_', base_name)[:30]
with open(pdf_path, 'rb') as f:
reader = PyPDF2.PdfReader(f)
img_count = 0
for page_num, page in enumerate(reader.pages):
# Check if page has images
if '/XObject' not in page.get('/Resources', {}):
continue
x_objects = page['/Resources']['/XObject'].get_object()
for obj_name in x_objects:
x_obj = x_objects[obj_name]
if x_obj['/Subtype'] == '/Image':
img_count += 1
# Determine image format and extract data
try:
width = x_obj['/Width']
height = x_obj['/Height']
# Get the filter type to determine format
img_filter = x_obj.get('/Filter', '')
if isinstance(img_filter, list):
img_filter = img_filter[0] if img_filter else ''
data = x_obj.get_data()
# Handle different image formats
if img_filter == '/DCTDecode':
# JPEG
ext = 'jpg'
img_filename = f"{base_name}_img_{img_count:03d}.{ext}"
img_path = os.path.join(output_dir, img_filename)
with open(img_path, 'wb') as img_file:
img_file.write(data)
elif img_filter == '/FlateDecode':
# PNG/raw - need to reconstruct
ext = 'png'
img_filename = f"{base_name}_img_{img_count:03d}.{ext}"
img_path = os.path.join(output_dir, img_filename)
color_space = x_obj.get('/ColorSpace', '/DeviceRGB')
if isinstance(color_space, list):
color_space = color_space[0]
if color_space == '/DeviceRGB':
mode = 'RGB'
elif color_space == '/DeviceCMYK':
mode = 'CMYK'
elif color_space == '/DeviceGray':
mode = 'L'
else:
mode = 'RGB'
bits = x_obj.get('/BitsPerComponent', 8)
if bits == 1:
mode = '1'
try:
img = Image.frombytes(mode, (width, height), data)
if mode == 'CMYK':
img = img.convert('RGB')
img.save(img_path, 'PNG')
except Exception:
# If reconstruction fails, skip this image
img_count -= 1
continue
elif img_filter == '/JPXDecode':
# JPEG2000
ext = 'jp2'
img_filename = f"{base_name}_img_{img_count:03d}.{ext}"
img_path = os.path.join(output_dir, img_filename)
with open(img_path, 'wb') as img_file:
img_file.write(data)
elif img_filter == '/CCITTFaxDecode':
# TIFF/Fax - skip for now as reconstruction is complex
img_count -= 1
continue
else:
# Try to save raw data and let PIL figure it out
ext = 'png'
img_filename = f"{base_name}_img_{img_count:03d}.{ext}"
img_path = os.path.join(output_dir, img_filename)
try:
from io import BytesIO
img = Image.open(BytesIO(data))
img.save(img_path, 'PNG')