Jesse A. Green / notebook

Live demo: the DocCompiler guide library as a static, read-only snapshot (October 2026). No backend, no accounts, nothing to submit.

MoneyPrinterV2 Administrator & Developer Guide

Generated April 2026 from FujiwaraChoki/MoneyPrinterV2 at b8fd5ce

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

This guide is the complete operational and developer reference for MoneyPrinterV2 — a Python pipeline that uses a local Ollama LLM, the Nano Banana 2 (Gemini) image API, KittenTTS, MoviePy, and Selenium-controlled Firefox to generate and publish short-form video and text content to YouTube Shorts and X (Twitter), with optional Post Bridge cross-posting to TikTok and Instagram, plus Amazon-affiliate pitch generation and Google-Maps-driven cold email outreach.

1. Architecture Overview

MoneyPrinterV2 is a single-process Python application with five major sub-pipelines, all coordinated from src/main.py through a stack of menu loops. There is no API server, no database, and no orchestration layer — state is persisted in JSON caches inside a hidden .mp/ directory at the project root.

High-level pipeline (YouTube Shorts)

                       config.json
                            |
                            v
+--------------------+  +-----------------+  +------------------+
| 1. Topic & Script  |  | 2. Image Prompts|  | 3. Image Render  |
| Ollama HTTP API    |->| Ollama HTTP API |->| Nano Banana 2 /  |
| llm_provider.py    |  | (JSON array)    |  | Gemini REST API  |
+--------------------+  +-----------------+  +------------------+
                                                       |
                                                       v
                                              +------------------+
                                              | .mp/<uuid>.png   |
                                              +------------------+
                                                       |
+--------------------+  +-----------------+  +------------------+
| 6. Final MP4       |  | 5. Subtitles    |  | 4. Voiceover     |
| MoviePy + Songs/*  |<-| Whisper local / |<-| KittenTTS local  |
| ImageMagick        |  | AssemblyAI API  |  | classes/Tts.py   |
+--------------------+  +-----------------+  +------------------+
        |
        v
+--------------------+  +---------------------------+
| 7. YouTube Upload  |->| 8. Post Bridge Cross-post |
| Selenium + Firefox |  | TikTok + Instagram (HTTP) |
+--------------------+  +---------------------------+
        |
        v
   .mp/youtube.json (per-account cache of uploaded videos)

The Twitter, Affiliate Marketing, and Outreach flows reuse the same Ollama-and-Selenium-and-Firefox plumbing, with smaller pipelines:

Twitter:    Ollama -> compose post -> Selenium @ x.com/compose/post
Affiliate:  Selenium @ Amazon -> Ollama (pitch) -> Twitter post
Outreach:   Go scraper (Google Maps) -> regex email -> yagmail SMTP

Component inventory

Component Path Role
Entry point src/main.py ASCII banner, first-run setup, model selection, top-level menu loop.
CRON entry src/cron.py Headless Twitter/YouTube run for one cached account. Invoked by the in-process scheduler or a system scheduler.
Config loader src/config.py Per-key getters reading config.json on demand. Defines ROOT_DIR from sys.path[0].
Cache layer src/cache.py JSON caches at .mp/twitter.json, .mp/youtube.json, .mp/afm.json.
LLM gateway src/llm_provider.py Thin wrapper around the official ollama Python client.
Status helpers src/status.py Coloured error/success/info/warning/question print helpers.
Utilities src/utils.py Songs archive download, .mp/ cleanup, Selenium killer, URL builder.
Constants src/constants.py Menu options, hardcoded selectors for X and YouTube Studio.
Banner src/art.py Reads assets/banner.txt, prints in green.
YouTube class src/classes/YouTube.py The full Shorts pipeline (~880 lines).
Twitter class src/classes/Twitter.py Posting and post-cache management.
AffiliateMarketing src/classes/AFM.py Amazon scrape + Twitter share.
Outreach src/classes/Outreach.py Go scraper + regex email + yagmail.
TTS src/classes/Tts.py KittenTTS (KittenML/kitten-tts-mini-0.8) at 24 kHz.
PostBridge client src/classes/PostBridge.py Thin retrying HTTP client for the Post Bridge API.
Cross-post glue src/post_bridge_integration.py Account resolution, caption building, conditional cross-post.

Technology stack

Layer Library Notes
LLM ollama (HTTP) Local-only by default. Base URL configurable.
Image Nano Banana 2 / Gemini REST gemini-3.1-flash-image-preview is the default; aspect ratio, model, and base URL configurable.
TTS kittentts Wheel pinned to KittenTTS 0.8.1 GitHub release in requirements.txt.
STT faster-whisper (local) or assemblyai (cloud) Selectable via stt_provider.
Video moviepy Requires ImageMagick on PATH or via imagemagick_path.
Subtitle equalize srt_equalizer 10-character cap applied in combine().
Browser selenium + selenium_firefox + webdriver_manager Firefox-only; geckodriver auto-managed.
SMTP yagmail Used by Outreach.
Scheduler schedule In-process, blocking.
Tables prettytable Terminal output for caches.
Colour termcolor All status output.

Note

The default LLM, image, TTS, STT, and posting choices are biased toward local-first operation. The cloud paths (Gemini, AssemblyAI, Post Bridge) are opt-in via config.json. This is a design constraint: the author wants the basic content pipeline to run without a credit card.

Data flow boundaries

  • All persistent state lives in <repo_root>/.mp/. Wiping .mp/ resets the app.
  • Credentials live exclusively in <repo_root>/config.json. There are no environment-loaded .env files.
  • Two environment variables are read as fallbacks: GEMINI_API_KEY and POST_BRIDGE_API_KEY.
  • Generated media (PNG, WAV, SRT, MP4) is also written to .mp/. rem_temp_files() purges everything except .json files at the start of each YouTube generation run.

2. System Requirements

Runtime requirements

Item Required Notes
Python 3.10+ Uses str | None type-hint syntax.
Operating system macOS, Linux, Windows 10+ All three are exercised; Windows-specific code paths exist (taskkill /f /im firefox.exe, .exe binary suffix).
Memory 8 GB minimum, 16 GB recommended Whisper base model fits in ~1 GB; larger Whisper models or larger Ollama models push this up quickly.
Disk 5 GB+ Ollama models alone can be 4 GB+; intermediate .mp/ artefacts and the songs archive add to that.
Network Outbound to Ollama (localhost), Gemini, AssemblyAI (optional), Post Bridge (optional), SMTP (optional), GitHub (one-time for kittentts wheel and geckodriver). No inbound ports are opened.

External binaries

Binary Required Used for
Firefox Yes (any flow that posts/uploads) Selenium target browser.
geckodriver Auto-managed Downloaded by webdriver_manager on first run.
ImageMagick (magick or convert) Yes (any YouTube generation) MoviePy TextClip rasterises captions through ImageMagick.
Ollama daemon Yes (LLM-driven flows) All script, title, post, and pitch generation.
Go toolchain (go) Yes (Outreach only) Builds the google-maps-scraper binary at runtime.

Pinned Python dependencies (from requirements.txt)

Package Notes
wheel Build helper for kittentts wheel.
termcolor Coloured status output.
schedule In-process scheduler.
requests All HTTP (Gemini, Post Bridge, scraper download, page fetch).
kittentts @ https://github.com/KittenML/KittenTTS/releases/download/0.8.1/kittentts-0.8.1-py3-none-any.whl Pinned to release 0.8.1.
soundfile KittenTTS audio writer.
prettytable Terminal tables.
webdriver_manager Auto-fetches geckodriver.
selenium_firefox Firefox-flavoured Selenium helpers.
selenium Core Selenium.
ollama Official Ollama Python client.
moviepy Video assembly.
Pillow>=10.0.0 Image handling.
yagmail SMTP for Outreach.
assemblyai Optional cloud STT.
faster-whisper Default local STT.
srt_equalizer Subtitle line-length capping.
undetected_chromedriver Imported by selenium_firefox transitively.
platformdirs Cross-platform path helpers.

Note

The repository declares Python 3.10+ via the str | None syntax in src/llm_provider.py and uses dict | None-style hints in src/post_bridge_integration.py. Python 3.9 will fail to import these modules.

3. Configuration Reference

The application reads <repo_root>/config.json on demand — every getter in src/config.py opens the file each call. There is no startup-time config parse, no schema validation, and no reload event; edits take effect on the next read. Defaults are applied per key (some via dict.get(key, default), some via Python or fallbacks).

Top-level keys

Key Type Default Reader Description
verbose bool (required) get_verbose() Toggles info() and per-step debug logging across the app.
firefox_profile string (required) get_firefox_profile_path() Default Firefox profile path. Overridable per cached account.
headless bool (required) get_headless() Adds --headless to Firefox options when True.
ollama_base_url string http://127.0.0.1:11434 get_ollama_base_url() Ollama HTTP endpoint.
ollama_model string "" get_ollama_model() If non-empty, the app calls select_model() non-interactively.
twitter_language string (required) get_twitter_language() Language passed into the Ollama prompt for tweet generation.
nanobanana2_api_base_url string https://generativelanguage.googleapis.com/v1beta get_nanobanana2_api_base_url() Override only for self-hosted Gemini-compatible endpoints.
nanobanana2_api_key string "" get_nanobanana2_api_key() If empty, falls back to GEMINI_API_KEY env var.
nanobanana2_model string gemini-3.1-flash-image-preview get_nanobanana2_model() Image model name.
nanobanana2_aspect_ratio string 9:16 get_nanobanana2_aspect_ratio() Passed in imageConfig.aspectRatio.
threads int (required) get_threads() MoviePy write_videofile(threads=...).
zip_url string "" get_zip_url() Optional URL for a .zip of background music. Empty disables remote fetch (and the app raises if Songs/ is empty).
is_for_kids bool (required) get_is_for_kids() Drives YouTube Studio's "made for kids" radio.
google_maps_scraper string (required) get_google_maps_scraper_zip_url() URL to the gosom/google-maps-scraper source zip.
google_maps_scraper_niche string (required) get_google_maps_scraper_niche() Search niche written to niche.txt.
scraper_timeout int 300 get_scraper_timeout() Soft subprocess.run timeout in seconds.
outreach_message_subject string I have a question... get_outreach_message_subject() {{COMPANY_NAME}} substituted before send.
outreach_message_body_file string outreach_message.html get_outreach_message_body_file() Relative path to the HTML body template.
stt_provider string local_whisper get_stt_provider() One of local_whisper, third_party_assemblyai. Anything else falls back to local with a warning.
whisper_model string base get_whisper_model() faster-whisper model identifier.
whisper_device string auto get_whisper_device() auto, cpu, or cuda.
whisper_compute_type string int8 get_whisper_compute_type() int8, float16, etc.
assembly_ai_api_key string (required if used) get_assemblyai_api_key() Required when stt_provider is third_party_assemblyai.
tts_voice string Jasper get_tts_voice() KittenTTS voice ID.
font string (required) get_font() TTF filename inside fonts/.
imagemagick_path string (required) get_imagemagick_path() Absolute path to magick/convert.
script_sentence_length int 4 get_script_sentence_length() Number of short sentences in the generated script.
email.smtp_server string smtp.gmail.com (per example) get_email_credentials() Outreach SMTP host.
email.smtp_port int 587 (per example) get_email_credentials() Outreach SMTP port.
email.username string (required) get_email_credentials() Outreach sender username.
email.password string (required) get_email_credentials() Outreach sender password (plaintext).
post_bridge.enabled bool false get_post_bridge_config() Master switch for cross-posting.
post_bridge.api_key string "" get_post_bridge_config() Falls back to POST_BRIDGE_API_KEY env var.
post_bridge.platforms list[string] ["tiktok", "instagram"] get_post_bridge_config() Filtered to the supported set {"tiktok", "instagram"}.
post_bridge.account_ids list[int] [] get_post_bridge_config() Pre-resolved Post Bridge social account IDs. Skips the prompt.
post_bridge.auto_crosspost bool false get_post_bridge_config() If true, the cross-post fires without prompting. CRON requires this.

Important

ollama_base_url is read with a .get(..., default) fallback, so an unset key uses the default. verbose, firefox_profile, and several other keys are read with subscript syntax (json[...]) and will raise KeyError on a malformed config — so do not delete keys that exist in config.example.json, even if you intend to leave them blank.

Note

Per-account fields override the global firefox_profile. Each cached Twitter and YouTube account stores its own firefox_profile path, which is what the YouTube and Twitter classes actually use. The global firefox_profile is essentially documentation.

Sanitization rules in get_post_bridge_config()

Field Rule
platforms Lowercased, stripped, deduplicated, filtered to the supported set. Non-list values become [].
account_ids Coerced to int; non-numeric entries are dropped silently.
api_key Trimmed; falls back to POST_BRIDGE_API_KEY if blank.
enabled, auto_crosspost Coerced to bool.

Negative knowledge — what is NOT supported

These are the questions a typical reader will ask. The answer in every case is not in the shipped code.

  • No .env file loader. python-dotenv is not in requirements.txt.
  • No OpenAI / Anthropic / Cohere / Gemini text backends. llm_provider.py is hardcoded to Ollama.
  • No SDXL / DALL-E / Midjourney image backend. The image getter is generate_image_nanobanana2, hardcoded to Gemini.
  • No Pexels / Pixabay / Storyblocks stock-video integration. There is no stock video — visuals come from the Gemini image API and are stitched into a slideshow.
  • No Twitter API / X API v2 client. Posting goes through Selenium against x.com/compose/post.
  • No YouTube Data API client. Uploading goes through Selenium against YouTube Studio.
  • No TikTok or Instagram direct posting. Cross-posting requires Post Bridge.
  • No database. JSON caches in .mp/ are the source of truth.
  • No background queue. schedule runs in-process; the menu blocks until you Quit the loop.

4. Deployment & Scaling

This is a workstation-grade application. Treat any "deployment" as "running it on a single host with a desktop session and a real Firefox profile".

Single-host deployment (typical)

  1. Provision a VM or workstation with 8 GB+ RAM and Firefox installed.
  2. Install Python 3.10+, Ollama, ImageMagick. (See the User Guide's Installation section for the helper script.)
  3. git clone the repo, pip install -r requirements.txt, copy config.example.json to config.json.
  4. Pre-create the Firefox profiles you need by launching Firefox under each profile and signing into YouTube and X.
  5. ollama pull <model> for at least one model.
  6. Edit config.json with profile paths, model name, ImageMagick path, and any optional cloud keys.
  7. python src/main.py to verify, then either keep the menu running or invoke src/cron.py from a system scheduler.

Headless / server deployment

Concern Recommendation
Display On Linux, run under xvfb-run so Selenium has an X server even when headless mode is buggy.
Profile lifetime Keep one Firefox profile per social account, version-controlled outside the repo.
Service supervisor Use systemd (Linux) or NSSM/Task Scheduler (Windows) to run cron.py with the right environment.
Log capture The app writes nothing to disk by default — capture stdout/stderr at the supervisor level.
Restart policy The Twitter/YouTube classes raise on Firefox-profile errors; restart-on-failure with a backoff is sane.
# /etc/systemd/system/mp2-youtube.service
[Unit]
Description=MoneyPrinterV2 - YouTube Shorts cron run
After=network-online.target ollama.service

[Service]
User=mp2
WorkingDirectory=/opt/MoneyPrinterV2
ExecStart=/opt/MoneyPrinterV2/venv/bin/python /opt/MoneyPrinterV2/src/cron.py youtube <account_uuid> <ollama_model>
Restart=on-failure
RestartSec=120

[Install]
WantedBy=multi-user.target

Tip

Do not run multiple cron.py invocations against the same Firefox profile concurrently. Selenium-driven Firefox locks the profile directory; a second instance will refuse to launch.

Scaling out

The app is single-threaded except for MoviePy's encoder workers (threads). To scale:

Lever How
Multiple accounts Cache one account per provider per host. The cache supports many accounts but only one runs at a time.
Multiple hosts Replicate config.json and the relevant Firefox profile per host. There is no shared state, so .mp/ divergence is fine.
Faster encode Increase threads. MoviePy uses ffmpeg; CPU-bound.
Faster STT Switch stt_provider to third_party_assemblyai.
Faster image generation Use the fastest available Gemini image model (gemini-3.1-flash-image-preview is already the cheap-fast tier).

Note

The bottleneck is almost always (a) Selenium waiting on YouTube Studio, or (b) Whisper transcription. Profile a real run before tuning anything else.

5. Operations

First-run setup

git clone https://github.com/FujiwaraChoki/MoneyPrinterV2.git
cd MoneyPrinterV2
bash scripts/setup_local.sh    # macOS/Linux helper
# OR manual:
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
cp config.example.json config.json
$EDITOR config.json
python src/main.py

Operator checklist:

  • [ ] Python 3.10+ available on PATH
  • [ ] ollama serve running and reachable at ollama_base_url
  • [ ] At least one Ollama model pulled (ollama list)
  • [ ] ImageMagick reachable at imagemagick_path
  • [ ] Firefox profile pre-logged into target accounts
  • [ ] config.json populated; Songs/ either contains audio or zip_url resolves
  • [ ] .mp/ writable by the running user
  • [ ] If using Outreach: go on PATH, SMTP credentials valid
  • [ ] If using Post Bridge: API key set, account_ids resolved
  • [ ] config.json outside source control or in a private fork

Routine operations

Task Command / Action
Generate one Short interactively python src/main.py -> 1 -> Upload Short
Run one Short non-interactively python src/cron.py youtube <account_uuid> <ollama_model>
Run one Tweet non-interactively python src/cron.py twitter <account_uuid> <ollama_model>
Inspect cached YouTube shorts python src/main.py -> 1 -> select account -> 2. Show all Shorts
Wipe intermediate artefacts Delete everything in .mp/ except *.json
Re-fetch songs archive rm -rf Songs/ && python src/main.py
Reset all caches rm -rf .mp/ && python src/main.py
Add an account Use the menu — there is no headless add command.

Monitoring

The app emits one of four colourised statuses per significant step: error (red, ❌), success (green, ✅), info (magenta, ℹ️), warning (yellow, ⚠️). Capture these to your log aggregator at the process supervisor level.

Tip

When supervising via systemd, set Environment=PYTHONUNBUFFERED=1 so that print()-based status lines flush in near-real time.

Common runbooks

Stuck Selenium / Firefox: call close_running_selenium_instances() manually or run taskkill /f /im firefox.exe (Windows) / pkill firefox (Linux/macOS) before retrying. The app does not call this automatically between runs.

Stale Outreach scraper output: issue #256 — scraper_results.csv is reused even when the scraper failed or timed out. Manually delete it from .mp/scraper_results.csv between runs until the issue is resolved.

Cron silent failures: issue #229 — cron.py reads sys.argv[2] as the account UUID; if no account matches, it silently exits. Confirm the UUID with cat .mp/youtube.json (or twitter.json) before scheduling.

Note

Issue #230 tracks JSONDecodeError crashes on corrupted JSON caches. Treat .mp/*.json as data: back them up before manual editing, and never let two writers touch the same file at once.

6. Performance

There is no benchmarking harness in the repo, so the figures below are the design budgets implied by the code paths. Calibrate against your own runs.

Hot path — YouTube Shorts generation

Stage Driver Typical wall time Notes
Topic + script + metadata Ollama 5–30 s Three serial calls. Bigger Ollama models are quadratically slower.
Image prompts Ollama 5–15 s One call returning a JSON array.
Image generation Gemini API 3–15 s per image len(script) / 3 images per Short. Network-bound.
TTS KittenTTS local 1–5 s CPU-only, fast.
Subtitles Whisper or AssemblyAI 5–60 s Whisper depends on whisper_model and CPU; AssemblyAI is roughly script length.
MP4 encode MoviePy + ffmpeg 15–90 s Bound by threads and codec speed.
YouTube upload Selenium / YouTube Studio 60–180 s Network and YouTube ingestion latency.
Post Bridge cross-post Post Bridge HTTP 10–60 s Two API calls plus the multipart upload.

Tuning levers

Lever Effect
threads MoviePy write_videofile(threads=N). Set to physical core count.
whisper_model (tiny, base, small, medium) Trade off accuracy and speed. base is the default.
whisper_compute_type (int8, int8_float16, float16) int8 is fastest on CPU.
script_sentence_length Fewer sentences = fewer images, faster pipeline, shorter Shorts.
nanobanana2_model The "flash" variant is the cheapest and fastest Gemini image tier.
stt_provider third_party_assemblyai is faster than CPU Whisper for long scripts.

Memory

Whisper models dominate. Expect ~1 GB RSS for base and ~5 GB for large-v3. Keep one process per host unless you have RAM to spare.

Tip

If you see swapping during the encode step, MoviePy keeps every ImageClip in memory. Smaller images (Gemini 9:16 ≈ 1080 x 1920 PNG) mostly stay under control, but a 4-sentence script generates ~5–8 clips and a 12-sentence script can balloon. Consider a smaller script_sentence_length if you encode on a memory-constrained box.

7. Security

Security in MoneyPrinterV2 is almost entirely about API key handling and the legal/ethical surface of automated posting. There is no authentication layer, no network listener, and no multi-user concept — the threat model is "the operator's machine, or the operator's CI runner, has been compromised, or the operator misuses the tool".

API key handling — the heart of this section

config.json is the single secret store. It can hold:

Secret Where used Blast radius
nanobanana2_api_key (or GEMINI_API_KEY) Gemini image API Free tier limits, paid quota, Google account linkage.
assembly_ai_api_key AssemblyAI transcription Paid quota.
post_bridge.api_key (or POST_BRIDGE_API_KEY) Post Bridge API Anyone with this key can post on every connected social account in your Post Bridge workspace.
email.username / email.password SMTP outreach Full mailbox compromise. Gmail "App Passwords" only protect against password reuse, not against the host being compromised.

Caution

A leaked Post Bridge key is the worst-case scenario in this codebase. It can be used to post arbitrary content to your TikTok and Instagram accounts. Rotate it immediately at any sign of leakage. The same applies to Gemini and AssemblyAI keys for billing reasons. SMTP credentials, especially Gmail App Passwords, must be revoked from your Google account if config.json is ever exposed.

Warning

config.json is not in the shipped .gitignore. If you fork the repo and accidentally git add config.json, you will commit credentials. Add the file to your local .gitignore before doing anything else, and use git rm --cached config.json if it has already been staged.

  1. Never commit config.json. Add it to .gitignore in your fork.
  2. Prefer environment-variable injection in CI. The two officially supported overrides are GEMINI_API_KEY and POST_BRIDGE_API_KEY. Read other secrets from your CI's secret store and envsubst or jq them into config.json at deploy time.
  3. Rotate compromised keys immediately. If a config.json is ever shared, revoke and regenerate every key in it.
  4. Limit Post Bridge accounts. Only connect the social accounts you actually intend to drive. Disconnect others.
  5. Use Gmail App Passwords, never the primary password. Generate a dedicated app password for outreach so it can be revoked without disturbing other access.
  6. Treat .mp/*.json as PII. It contains every Twitter post body, every YouTube video title/description, and every Firefox profile path you have ever automated. Back them up encrypted; do not commit them.

Secret-handling layout for production

/opt/MoneyPrinterV2/                  (repo, owned by deploy user, mode 0750)
  config.json                         (mode 0600, owned by mp2 user, in .gitignore)
  .mp/                                (mode 0700, mp2 user only)
  Songs/                              (world-readable music)
  fonts/                              (world-readable assets)

/etc/mp2/env.d/post_bridge.env        (mode 0600, contains POST_BRIDGE_API_KEY=...)
/etc/mp2/env.d/gemini.env             (mode 0600, contains GEMINI_API_KEY=...)

Then your supervisor unit pulls the env files in:

EnvironmentFile=-/etc/mp2/env.d/post_bridge.env
EnvironmentFile=-/etc/mp2/env.d/gemini.env

Platform Terms of Service

Each social platform has its own automation rules. Read them before turning the bot loose.

Platform Posture Notes
YouTube Permissive for AI content if labelled. YouTube requires "altered or synthetic content" disclosure for realistic AI-generated content under their 2024 policy.
X (Twitter) Restrictive. Selenium-driven posting is automation; X's Developer Agreement and Automation Rules apply. Aggressive automation triggers shadowbans and suspensions.
TikTok Restrictive. Cross-posted via Post Bridge — Post Bridge enforces TikTok's policies on its end, but you remain responsible.
Instagram Restrictive. Same as TikTok — Post Bridge mediates.

Warning

Selenium-driven Twitter posting violates X's automation guidelines if done at high volume from a non-automation account. Use a dedicated automation account, vary posting frequency, and respect rate limits. Issue #246 (automated contextual replies) is open and explicitly calls out the platform-policy nuances.

Watermarking and disclosure

The app does not currently apply any "AI generated" watermark or metadata. To stay on the right side of YouTube's altered-media policy:

  • Add an "AI-generated" line to descriptions in your post-processing.
  • Tick the "altered content" toggle in YouTube Studio after the unlisted upload, before promoting to public.
  • Consider editing combine() in src/classes/YouTube.py to overlay a small disclosure caption.

Outreach (cold email) compliance

Cold email is regulated. The Outreach flow does not implement any of the following — that is your responsibility:

  • Unsubscribe link in outreach_message_body_file.
  • Physical postal address in the email body (CAN-SPAM).
  • Opt-out tracking so you do not re-email the same recipient.
  • Honoring replies and complaints.

Caution

Do not run the Outreach flow against any list you cannot justify under CAN-SPAM (US), CASL (Canada), GDPR (EU), or your local equivalent. The shipped code happily emails every result the Google Maps scraper finds; that is a CAN-SPAM violation in the US if you have no business relationship and no opt-out path.

Input handling and untrusted-data risks

Surface Risk Mitigation in the code
Songs zip extraction Path traversal Members with .. or absolute paths are skipped (utils.fetch_songs).
Google Maps scraper zip extraction Path traversal Same skip in Outreach.unzip_file.
Affiliate link Unvalidated browser navigation urlparse checks that scheme is http/https before opening.
Outreach scraped websites Unvalidated requests.get No URL allow-list; trusts whatever the scraper produced.
LLM output (script, title, prompts) Prompt injection from Ollama context Output is not exec'd or rendered as HTML; it goes into MoviePy text or YouTube Studio fields. Low risk.

Sandboxing

There is none. The app runs with full user privileges, drives Firefox under the operator's profile, and writes anywhere os.path.join(ROOT_DIR, ...) points. Run it inside a non-privileged service account, and consider a dedicated user just for the bot.

8. Developer Internals

Repository layout

.
├── LICENSE                          MIT
├── AGENTS.md, CLAUDE.md, CODE_OF_CONDUCT.md, CONTRIBUTING.md, README.md
├── config.example.json              Source of truth for default config schema
├── requirements.txt                 Pinned Python deps (incl. KittenTTS wheel URL)
├── .python-version                  Asks pyenv for the right Python
├── assets/banner.txt                ASCII banner printed at startup
├── docs/                            User-facing markdown (Configuration, Roadmap, ...)
├── fonts/bold_font.ttf              Default caption font
├── scripts/
│   ├── setup_local.sh               macOS/Linux installer
│   ├── preflight_local.py           Pre-launch dependency check
│   └── upload_video.sh              Adjacent helper
├── src/
│   ├── main.py                      Top-level menu loop (entry point)
│   ├── cron.py                      Headless single-run dispatcher
│   ├── art.py                       Banner printer
│   ├── cache.py                     JSON cache for accounts and products
│   ├── config.py                    All `get_*()` config readers
│   ├── constants.py                 Menu strings, X selectors, YouTube Studio IDs
│   ├── llm_provider.py              Ollama wrapper
│   ├── post_bridge_integration.py   Cross-post glue
│   ├── status.py                    Coloured print helpers
│   ├── utils.py                     Songs fetch, file cleanup, Selenium killer
│   └── classes/
│       ├── AFM.py                   Affiliate Marketing
│       ├── Outreach.py              Cold email outreach
│       ├── PostBridge.py            HTTP client for the Post Bridge API
│       ├── Tts.py                   KittenTTS wrapper
│       ├── Twitter.py               Selenium Twitter / X
│       └── YouTube.py               Full Shorts pipeline
└── tests/                           pytest tests (Outreach, cron, post-bridge)

Module reference

src/main.py (entry point)

__main__ does six things in sequence: print banner, detect first run, ensure .mp/ exists, clean .mp/ of non-JSON, fetch songs, choose Ollama model. Then it enters while True: main(). The main() function shows the four-option main menu and dispatches to the sub-pipeline for the chosen number.

src/cron.py

Three-arg CLI dispatcher invoked by both the in-process scheduler (schedule.every(...).do(job) where job is subprocess.run(["python", cron_script_path, ...])) and any external system scheduler. Reads sys.argv[1] (twitter or youtube), sys.argv[2] (account UUID), and sys.argv[3] (Ollama model). Bare-arg parsing — no argparse.

Note

Issue #229 calls out a silent-fail path: if the UUID does not match any cached account, the matching for loop falls through and the process exits 0. A real argparse and explicit error path is the simplest fix.

src/config.py

Reads config.json once per getter call. Side effect: on import, it sets ROOT_DIR = os.path.dirname(sys.path[0]), which is "the parent of the directory containing the entry-point script". This is why relative imports across src/ work without python -m.

It exposes every config key through a function, plus assert_folder_structure() (creates .mp/), get_first_time_running(), and equalize_subtitles().

Tip

If you want to load config.json only once per run (perf), introduce a functools.lru_cache(maxsize=1)-decorated _load() helper inside config.py and have all getters call it. This is a safe refactor because no caller mutates the dict.

src/cache.py

JSON-on-disk store. get_provider_cache_path("twitter"|"youtube") returns .mp/twitter.json or .mp/youtube.json. The get_accounts, add_account, and remove_account functions parse-mutate-rewrite the whole file each call.

Warning

No file lock. Two writers (e.g. a manual menu invocation and a cron run) racing on the same JSON cache will lose data. Issue #230 already tracks the related "corrupted JSON crashes the app" failure mode.

src/llm_provider.py

list_models(), select_model(name), get_active_model(), generate_text(prompt, model_name=None). _selected_model is a module-level global; tests should monkeypatch it explicitly. The generate_text call goes through ollama.Client(host=...).chat(...) and returns response["message"]["content"].strip().

Open issue #232 calls out the lack of error handling in this module — network errors propagate as raw requests/ollama exceptions.

src/classes/YouTube.py

The largest module. Pipeline ordering inside generate_video(tts_instance):

  1. generate_topic() — Ollama call, sets self.subject.
  2. generate_script() — Ollama, sets self.script. Retries if longer than 5000 chars; auto-strips * markdown.
  3. generate_metadata() — Ollama for title (≤100 chars, retry) and description, sets self.metadata.
  4. generate_prompts() — Ollama for a JSON array, sized to roughly len(self.script) / 3. Has a markdown-fence stripper and a regex fallback for malformed JSON.
  5. For each prompt, generate_image(prompt) (delegates to generate_image_nanobanana2) → writes a .png to .mp/ via _persist_image.
  6. generate_script_to_speech(tts_instance) — KittenTTS to a .wav in .mp/.
  7. combine() — MoviePy assembly:
    • Calculates per-image duration req_dur = max_duration / len(self.images).
    • Loops over images, ImageClip-cropping each to a 9:16 frame.
    • Calls generate_subtitles (which dispatches to local Whisper or AssemblyAI), then equalize_subtitles(srt_path, 10).
    • Composes a yellow stroked TextClip caption.
    • Mixes the TTS with choose_random_song() at 10% volume.
    • Writes the final MP4 with threads=get_threads().
  8. self.video_path = os.path.abspath(path) so the cross-poster can find it.

upload_video() is a single try/except: bare_except block that drives YouTube Studio: file picker, title, description, "made for kids" radio, three Next clicks, Unlisted radio, Done, then reads the latest video URL from studio.youtube.com/channel/{channel_id}/videos/short and persists it to the YouTube cache via add_video.

Note

The bare_except: self.browser.quit(); return False swallows everything. When investigating an upload failure, temporarily replace with except Exception as exc: warning(repr(exc)); raise to see the real cause.

src/classes/Twitter.py

The constructor opens a Firefox driver against fp_profile_path and caches a WebDriverWait(self.browser, 30). post(text=None) either takes pre-formed text or calls generate_post() (Ollama, 2-sentence limit, 260-char cap, * stripped). Three textarea selectors and three post-button selectors are tried in order; whichever clicks first wins. After a successful post, the body is appended to .mp/twitter.json under the matching account.

src/classes/AFM.py (Affiliate Marketing)

Constructor: validates firefox_profile, validates the affiliate URL with urlparse, opens the link in Firefox, scrapes AMAZON_PRODUCT_TITLE_ID (productTitle) and AMAZON_FEATURE_BULLETS_ID (feature-bullets). generate_pitch() runs an Ollama prompt and appends the affiliate URL. share_pitch("twitter") instantiates a Twitter client for the same account and posts the pitch.

src/classes/Outreach.py

Wraps a Go binary. Open issue #228 flags multiple security sub-issues: unsafe os.system("go version") Go check (string injection if attacker controls PATH), SMTP credential leak in tracebacks, unsafe os.remove("niche.txt") outside the project root, naive CSV parsing that breaks on commas in fields. Issue #256 flags stale result-file reuse. Issue #184 (closed) was about an unclosed file handle.

src/classes/PostBridge.py

Pure-HTTP client for https://api.post-bridge.com/v1. Three public methods:

Method Endpoint Returns
list_social_accounts(platforms=None, limit=100) GET /social-accounts Pagination-aware list of accounts. Raises PostBridgeClientError on non-list payload.
upload_media(file_path) POST /media/create-upload-url then PUT upload_url Media ID.
create_post(caption, social_account_ids, media_ids=None, platform_configurations=None, scheduled_at=None, processing_enabled=True) POST /posts The created-post payload.

Internals: _request retries on {429, 500, 502, 503, 504} up to max_retries=3 with 0.5 * attempt second back-offs. _request_json wraps it and raises a structured PostBridgeClientError on non-JSON responses.

src/post_bridge_integration.py

Three top-level functions:

  • resolve_social_account_ids(...) — Returns configured IDs if set; otherwise fetches list_social_accounts(platforms) and either auto-picks the only option per platform, prompts in interactive mode, or refuses in non-interactive mode.
  • build_platform_configurations(title) — Currently only sets a TikTok-side title override.
  • maybe_crosspost_youtube_short(video_path, title, interactive, prompt_fn=None) — The orchestrator. Skips on enabled=false, missing API key, missing video file, empty platforms; respects auto_crosspost for the cron path; uploads media; creates the post; relays Post Bridge warnings.

src/classes/Tts.py

KITTEN_MODEL = "KittenML/kitten-tts-mini-0.8"
KITTEN_SAMPLE_RATE = 24000

class TTS:
    def __init__(self) -> None:
        self._model = KittenModel(KITTEN_MODEL)
        self._voice = get_tts_voice()

    def synthesize(self, text, output_file=...):
        audio = self._model.generate(text, voice=self._voice)
        sf.write(output_file, audio, KITTEN_SAMPLE_RATE)
        return output_file

Open issue #231 calls out the missing error handling here. KittenTTS failures bubble up as raw exceptions; wrap calls in your own try/except if you depend on a successful run.

Code patterns and conventions

Pattern Where Notes
Star imports (from cache import *) main.py, cron.py, classes/*.py Idiomatic in this codebase, used to flatten access. Be careful when adding modules — names will leak across files.
os.path.join(ROOT_DIR, ...) for filesystem Everywhere Single anchor for paths. Override ROOT_DIR only if you fully understand the import-side-effect in config.py.
Coloured status helpers (success, error, warning, info, question) Everywhere Prefer these over print(); they keep the terminal output consistent.
if get_verbose(): info(...) Everywhere The verbose flag is checked per call, not at start. Cheap, but worth caching at the call site if perf matters.
Bare except YouTube.upload_video(), a few helpers Swallows everything. Replace with typed exceptions during debugging.
Selenium retry-by-selector Twitter.post(), YouTube.upload_video() Multiple selectors are tried in order; first success wins. Good defensive pattern against UI churn.

Testing strategy

The tests/ folder contains four pytest files:

  • tests/test_config.py — Config defaults and get_post_bridge_config sanitization.
  • tests/test_post_bridge_client.py — Client-side error paths and retry behaviour.
  • tests/test_post_bridge_integration.py — resolve_social_account_ids, build_platform_configurations, and maybe_crosspost_youtube_short decision tree.
  • tests/test_cron_post_bridge.py — The cron path's interaction with cross-post defaults.

Run with:

source venv/bin/activate
pytest -q

Note

There is no integration coverage for YouTube.combine(), the Selenium flows, or the Outreach Go binary. End-to-end runs require live Ollama, live Gemini, live Firefox profiles, and live YouTube Studio — they cannot be unit-tested. Treat them as smoke tests driven from real accounts you own.

Suggested improvements (tracked or implied by issues)

  • #232 — Add try/except around ollama.Client.chat() and list() calls; surface actionable errors. (BUG)
  • #231 — Wrap TTS.synthesize to surface KittenTTS errors. (BUG)
  • #230 — Catch JSONDecodeError in cache.py getters; rename and re-create on corruption. (BUG)
  • #229 — Add explicit account-not-found error in cron.py. Switch to argparse. (BUG)
  • #228 — Replace os.system("go version") with a subprocess call; redact SMTP creds from tracebacks; constrain os.remove("niche.txt") to the project root; switch to the csv module. (BUG/SEC)
  • #256 — Treat scraper_results.csv as run-scoped: write to a per-run path or wipe before each run. (BUG)
  • #246 — Automated contextual Twitter replies. (FEATURE)
  • #199 (closed) — Vision-QA for weak hooks/text overlays. Would need to read the rendered MP4 frame-by-frame.
  • #195 (closed) — Content-lane taxonomy.

Contributing

The repo accepts PRs through GitHub. Match the existing code style (snake_case, tab-free indentation in Python, star imports tolerated in top-level scripts). Add tests under tests/ for any non-Selenium behaviour. Keep config.example.json in sync with any new config keys you introduce — the get_*() getter must default sensibly when the key is absent.

9. Integration Points

Outbound integrations (the app calls)

Integration Endpoint / Surface Auth Failure mode
Ollama http://127.0.0.1:11434 (configurable) None RuntimeError("No Ollama model selected.") if no model picked; RuntimeError("Could not connect to Ollama: ...") from raw exceptions on connect failure.
Nano Banana 2 / Gemini https://generativelanguage.googleapis.com/v1beta/models/<model>:generateContent (configurable) x-goog-api-key header Returns None and logs a warning; pipeline continues with whatever images succeeded.
AssemblyAI Default AssemblyAI cloud endpoint via the assemblyai SDK API key in config Bubble-up exception.
Whisper local faster-whisper Python lib None ImportError if faster-whisper is missing → guidance message; raised.
Post Bridge https://api.post-bridge.com/v1 Authorization: Bearer <api_key> PostBridgeClientError with status code; auto-retried on 429/5xx; final failure logged as warning.
YouTube Studio https://www.youtube.com/upload, https://studio.youtube.com/... Firefox profile session try/except: return False; cross-post is then skipped.
X / Twitter https://x.com/compose/post Firefox profile session RuntimeError with explicit message about missing textbox or post button.
Amazon (affiliate page) Affiliate URL provided by user None (browser session) Selenium element-not-found exception; not specially handled.
gosom/google-maps-scraper GitHub release zip URL None requests exception, then FileNotFoundError if the binary is missing.
SMTP (Outreach) Configured SMTP host/port Username/password yagmail exception; logged and the loop continues to the next recipient.

Inbound integrations

There are none. The app does not open any listening sockets. It is a command-line program.

Background music archive

utils.fetch_songs() will (a) skip if Songs/ already contains audio, (b) try zip_url from config first, then DEFAULT_SONG_ARCHIVE_URLS (currently []). It validates ZIP entries against a safe-extension allowlist (.mp3, .wav, .m4a, .aac, .ogg, .flac) and suspicious paths (.., leading /).

Tip

The shipped repository ships no default song archive URL. You must either pre-populate Songs/ or set zip_url in your config. First-launch on a fresh checkout will print Could not download a valid songs archive from any configured URL if neither is in place — that is expected and survivable; you just cannot generate Shorts until you fix it.

Extension points

  • Add a new image backend. YouTube.generate_image() currently delegates to generate_image_nanobanana2. Add a sibling generate_image_<provider>(), branch on a new image_provider config key, and route accordingly. Keep the contract: take prompt: str, return path: str (PNG written to .mp/) or None on failure.
  • Add a new LLM backend. llm_provider.py is the right place. Match the existing public surface (list_models, select_model, get_active_model, generate_text(prompt, model_name=None)) so the YouTube/Twitter/AFM classes do not need to change.
  • Add a new social platform. Either extend selenium-firefox-driven posting (more brittle) or extend Post Bridge's platforms whitelist in get_post_bridge_config() once Post Bridge officially supports the platform. Note that supported_platforms = {"tiktok", "instagram"} is a hardcoded set in config.py.
  • Add a new STT provider. Extend YouTube.generate_subtitles() with another provider == "..." branch and a sibling generate_subtitles_<name>() method that returns a path to a .srt.

Public CLI invocations

Command Purpose
python src/main.py Interactive entry point.
python src/cron.py twitter <account_uuid> <ollama_model> Headless Twitter post for one cached account.
python src/cron.py youtube <account_uuid> <ollama_model> Headless YouTube generation + upload + (optional) cross-post for one cached account.
bash scripts/setup_local.sh macOS/Linux installer.
python scripts/preflight_local.py Pre-launch dependency checks.
pytest -q Run the test suite.