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.envfiles. - Two environment variables are read as fallbacks:
GEMINI_API_KEYandPOST_BRIDGE_API_KEY. - Generated media (PNG, WAV, SRT, MP4) is also written to
.mp/.rem_temp_files()purges everything except.jsonfiles 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 | Nonesyntax insrc/llm_provider.pyand usesdict | None-style hints insrc/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_urlis 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 raiseKeyErroron a malformed config — so do not delete keys that exist inconfig.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 ownfirefox_profilepath, which is what the YouTube and Twitter classes actually use. The globalfirefox_profileis 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
.envfile loader.python-dotenvis not inrequirements.txt. - No OpenAI / Anthropic / Cohere / Gemini text backends.
llm_provider.pyis 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.
scheduleruns in-process; the menu blocks until youQuitthe 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)
- Provision a VM or workstation with 8 GB+ RAM and Firefox installed.
- Install Python 3.10+, Ollama, ImageMagick. (See the User Guide's Installation section for the helper script.)
git clonethe repo,pip install -r requirements.txt, copyconfig.example.jsontoconfig.json.- Pre-create the Firefox profiles you need by launching Firefox under each profile and signing into YouTube and X.
ollama pull <model>for at least one model.- Edit
config.jsonwith profile paths, model name, ImageMagick path, and any optional cloud keys. python src/main.pyto verify, then either keep the menu running or invokesrc/cron.pyfrom 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.pyinvocations 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 serverunning and reachable atollama_base_url - [ ] At least one Ollama model pulled (
ollama list) - [ ] ImageMagick reachable at
imagemagick_path - [ ] Firefox profile pre-logged into target accounts
- [ ]
config.jsonpopulated;Songs/either contains audio orzip_urlresolves - [ ]
.mp/writable by the running user - [ ] If using Outreach:
goon PATH, SMTP credentials valid - [ ] If using Post Bridge: API key set,
account_idsresolved - [ ]
config.jsonoutside 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, setEnvironment=PYTHONUNBUFFERED=1so thatprint()-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
JSONDecodeErrorcrashes on corrupted JSON caches. Treat.mp/*.jsonas 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_lengthif 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.jsonis ever exposed.
Warning
config.jsonis not in the shipped.gitignore. If you fork the repo and accidentallygit add config.json, you will commit credentials. Add the file to your local.gitignorebefore doing anything else, and usegit rm --cached config.jsonif it has already been staged.
Recommended handling
- Never commit
config.json. Add it to.gitignorein your fork. - Prefer environment-variable injection in CI. The two officially supported overrides are
GEMINI_API_KEYandPOST_BRIDGE_API_KEY. Read other secrets from your CI's secret store andenvsubstorjqthem intoconfig.jsonat deploy time. - Rotate compromised keys immediately. If a
config.jsonis ever shared, revoke and regenerate every key in it. - Limit Post Bridge accounts. Only connect the social accounts you actually intend to drive. Disconnect others.
- Use Gmail App Passwords, never the primary password. Generate a dedicated app password for outreach so it can be revoked without disturbing other access.
- Treat
.mp/*.jsonas 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. |
| 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()insrc/classes/YouTube.pyto 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
forloop falls through and the process exits 0. A realargparseand 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.jsononly once per run (perf), introduce afunctools.lru_cache(maxsize=1)-decorated_load()helper insideconfig.pyand 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):
generate_topic()— Ollama call, setsself.subject.generate_script()— Ollama, setsself.script. Retries if longer than 5000 chars; auto-strips*markdown.generate_metadata()— Ollama for title (≤100 chars, retry) and description, setsself.metadata.generate_prompts()— Ollama for a JSON array, sized to roughlylen(self.script) / 3. Has a markdown-fence stripper and a regex fallback for malformed JSON.- For each prompt,
generate_image(prompt)(delegates togenerate_image_nanobanana2) → writes a.pngto.mp/via_persist_image. generate_script_to_speech(tts_instance)— KittenTTS to a.wavin.mp/.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), thenequalize_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().
- Calculates per-image duration
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 Falseswallows everything. When investigating an upload failure, temporarily replace withexcept Exception as exc: warning(repr(exc)); raiseto 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 fetcheslist_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 onenabled=false, missing API key, missing video file, empty platforms; respectsauto_crosspostfor 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 andget_post_bridge_configsanitization.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, andmaybe_crosspost_youtube_shortdecision 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()andlist()calls; surface actionable errors. (BUG) - #231 — Wrap
TTS.synthesizeto surface KittenTTS errors. (BUG) - #230 — Catch
JSONDecodeErrorincache.pygetters; rename and re-create on corruption. (BUG) - #229 — Add explicit account-not-found error in
cron.py. Switch toargparse. (BUG) - #228 — Replace
os.system("go version")with asubprocesscall; redact SMTP creds from tracebacks; constrainos.remove("niche.txt")to the project root; switch to thecsvmodule. (BUG/SEC) - #256 — Treat
scraper_results.csvas 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 setzip_urlin your config. First-launch on a fresh checkout will printCould not download a valid songs archive from any configured URLif 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 togenerate_image_nanobanana2. Add a siblinggenerate_image_<provider>(), branch on a newimage_providerconfig key, and route accordingly. Keep the contract: takeprompt: str, returnpath: str(PNG written to.mp/) orNoneon failure. - Add a new LLM backend.
llm_provider.pyis 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'splatformswhitelist inget_post_bridge_config()once Post Bridge officially supports the platform. Note thatsupported_platforms = {"tiktok", "instagram"}is a hardcoded set inconfig.py. - Add a new STT provider. Extend
YouTube.generate_subtitles()with anotherprovider == "..."branch and a siblinggenerate_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. |