# MoneyPrinterV2 Administrator & Developer Guide

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. |

```ini
# /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

```bash
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](https://github.com/FujiwaraChoki/MoneyPrinterV2/issues/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](https://github.com/FujiwaraChoki/MoneyPrinterV2/issues/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](https://github.com/FujiwaraChoki/MoneyPrinterV2/issues/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.

#### Recommended handling

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:

```ini
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](https://github.com/FujiwaraChoki/MoneyPrinterV2/issues/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](https://github.com/FujiwaraChoki/MoneyPrinterV2/issues/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](https://github.com/FujiwaraChoki/MoneyPrinterV2/issues/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](https://github.com/FujiwaraChoki/MoneyPrinterV2/issues/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](https://github.com/FujiwaraChoki/MoneyPrinterV2/issues/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](https://github.com/FujiwaraChoki/MoneyPrinterV2/issues/256) flags stale
result-file reuse. Issue [#184](https://github.com/FujiwaraChoki/MoneyPrinterV2/issues/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`

```python
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](https://github.com/FujiwaraChoki/MoneyPrinterV2/issues/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:

```bash
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. |

---

_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. Unofficial; not affiliated with or reviewed by the project._
