Files
wehub-resource-sync 9a6da7d40d
Publish Docker image / Build and publish (push) Failing after 0s
CI / Python tests (push) Failing after 4s
chore: import upstream snapshot with attribution
2026-07-13 12:03:55 +08:00

641 lines
23 KiB
Python

#!/usr/bin/env python3
"""Cross-platform installation and video generation for the MoneyPrinterTurbo Skill."""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.error
import urllib.request
import uuid
import zipfile
from datetime import datetime, timezone
from pathlib import Path
PROJECT_ARCHIVE_URL = (
"https://github.com/harry0703/MoneyPrinterTurbo/archive/refs/heads/main.zip"
)
DEFAULT_ROOT = Path.home() / "MoneyPrinterTurbo"
DEFAULT_VOICE_NAME = "zh-CN-XiaoxiaoNeural-Female"
NEEDS_INPUT_EXIT_CODE = 10
SUPPORTED_SOURCES = {"pexels", "pixabay", "coverr", "local"}
PEXELS_API_KEY_URL = "https://www.pexels.com/api/"
PEXELS_VALIDATION_URL = "https://api.pexels.com/v1/collections?per_page=1"
PEXELS_API_KEY_HELP_URL = (
"https://help.pexels.com/hc/en-us/articles/"
"900004904026-How-do-I-get-an-API-key"
)
# Keep the recommended list focused on commonly used providers. When an LLM
# key is missing, the helper emits all choices at once to avoid extra turns.
RECOMMENDED_LLM_PROVIDERS = {
"moonshot": (
"Kimi / Moonshot AI",
"https://platform.kimi.com/console/api-keys?aff=MoneyPrinterTurbo",
),
"openai": ("OpenAI", "https://platform.openai.com/api-keys"),
"gemini": ("Google Gemini", "https://aistudio.google.com/app/apikey"),
"deepseek": ("DeepSeek", "https://platform.deepseek.com/api_keys"),
"volcengine": (
"ByteDance VolcEngine Ark / Doubao",
"https://www.volcengine.com/activity/ai618?utm_source=MoneyPrinterTurbo",
),
"minimax": ("MiniMax", "https://platform.minimax.io/"),
"mimo": (
"Xiaomi MiMo",
"https://platform.xiaomimimo.com/docs/zh-CN/quick-start/first-api-call",
),
}
KEYLESS_LLM_PROVIDERS = {"ollama", "litellm"}
CUSTOM_OPENAI_PROVIDER = "oneapi"
# Hidden providers such as Qwen, Azure, and Grok remain usable when already
# selected, but are not automatic fallback candidates. A fully configured
# generic OpenAI-compatible endpoint can be reused safely.
ADDITIONAL_REUSABLE_PROVIDERS = (CUSTOM_OPENAI_PROVIDER,)
class SkillError(RuntimeError):
"""An actionable Skill error that can be reported without a traceback."""
def log(message: str) -> None:
"""Flush concise progress so the agent knows the long-running job started."""
print(f"[MoneyPrinterTurbo] {message}", flush=True)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Install MoneyPrinterTurbo and generate a final video from a topic."
)
parser.add_argument("--subject", required=True, help="video topic")
parser.add_argument(
"--root",
type=Path,
default=DEFAULT_ROOT,
help=f"MoneyPrinterTurbo installation directory (default: {DEFAULT_ROOT})",
)
parser.add_argument(
"cli_args",
nargs=argparse.REMAINDER,
help="additional MoneyPrinterTurbo CLI arguments placed after --",
)
args = parser.parse_args(argv)
args.subject = args.subject.strip()
if not args.subject:
parser.error("--subject cannot be empty")
if args.cli_args and args.cli_args[0] == "--":
args.cli_args = args.cli_args[1:]
return args
def _safe_extract(archive: zipfile.ZipFile, destination: Path) -> None:
"""Reject ZIP entries that would escape the temporary extraction directory."""
destination = destination.resolve()
for member in archive.infolist():
target = (destination / member.filename).resolve()
if target != destination and destination not in target.parents:
raise SkillError(f"project archive contains an unsafe path: {member.filename}")
archive.extractall(destination)
def ensure_project(root: Path) -> None:
"""Reuse an existing project or install it from the official GitHub archive."""
root = root.expanduser().resolve()
if (root / "cli.py").is_file() and (root / "config.example.toml").is_file():
log(f"using existing project: {root}")
return
if root.exists() and any(root.iterdir()):
raise SkillError(f"installation directory exists but is not a valid project: {root}")
root.parent.mkdir(parents=True, exist_ok=True)
log(f"first-time installation: downloading the official project to {root}")
with tempfile.TemporaryDirectory(prefix="mpt-install-") as temp_dir_value:
temp_dir = Path(temp_dir_value)
archive_path = temp_dir / "MoneyPrinterTurbo.zip"
request = urllib.request.Request(
PROJECT_ARCHIVE_URL,
headers={"User-Agent": "MoneyPrinterTurbo-Agent-Skill"},
)
with urllib.request.urlopen(request, timeout=120) as response:
# Stream the archive to avoid holding a second full copy in memory.
with archive_path.open("wb") as archive_file:
shutil.copyfileobj(response, archive_file)
with zipfile.ZipFile(archive_path) as archive:
_safe_extract(archive, temp_dir)
candidates = [
path
for path in temp_dir.iterdir()
if path.is_dir() and (path / "cli.py").is_file()
]
if len(candidates) != 1:
raise SkillError("download completed but no valid MoneyPrinterTurbo project was found")
if root.exists():
root.rmdir()
shutil.move(str(candidates[0]), str(root))
log("project download completed")
def ensure_config(root: Path) -> Path:
"""Create the initial configuration without overwriting an existing file."""
config_path = root / "config.toml"
if not config_path.exists():
shutil.copy2(root / "config.example.toml", config_path)
log(f"created configuration file: {config_path}")
return config_path
def _plain_config_value(text: str, key: str) -> str:
"""Read a simple top-level TOML value without printing its contents."""
match = re.search(rf"(?m)^{re.escape(key)}\s*=\s*(.*)$", text)
if not match:
return ""
value = match.group(1).split("#", 1)[0].strip()
if value.startswith('"') and value.endswith('"'):
return value[1:-1]
return value
def _replace_config_value(text: str, key: str, value: object) -> str:
"""Replace one active field while preserving the configuration layout."""
pattern = re.compile(rf"(?m)^({re.escape(key)}\s*=\s*).*$")
if not pattern.search(text):
raise SkillError(f"configuration field not found in config.toml: {key}")
encoded = json.dumps(value, ensure_ascii=False)
return pattern.sub(lambda match: f"{match.group(1)}{encoded}", text, count=1)
def _has_configured_value(value: str) -> bool:
"""Treat empty strings and whitespace-only key arrays as unconfigured."""
if not value:
return False
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return bool(value.strip())
if isinstance(parsed, list):
return any(str(item).strip() for item in parsed)
return bool(str(parsed).strip())
def _parse_string_list(value: str) -> list[str]:
"""Parse a configured string list while removing blanks and duplicates."""
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return []
if not isinstance(parsed, list):
return []
return list(dict.fromkeys(str(item).strip() for item in parsed if str(item).strip()))
def apply_environment_config(config_path: Path) -> None:
"""Write supplied credentials while logging field names only."""
provider = os.environ.get("MPT_LLM_PROVIDER", "").strip().lower()
if provider == "openai_compatible":
provider = CUSTOM_OPENAI_PROVIDER
llm_key = os.environ.get("MPT_LLM_API_KEY", "").strip()
base_url = os.environ.get("MPT_LLM_BASE_URL", "").strip()
model_name = os.environ.get("MPT_LLM_MODEL_NAME", "").strip()
pexels_key = os.environ.get("MPT_PEXELS_API_KEY", "").strip()
if not any((provider, llm_key, base_url, model_name, pexels_key)):
return
text = config_path.read_text(encoding="utf-8")
current_provider = _plain_config_value(text, "llm_provider") or "moonshot"
provider = provider or current_provider
changes: list[str] = []
if os.environ.get("MPT_LLM_PROVIDER", "").strip():
text = _replace_config_value(text, "llm_provider", provider)
changes.append("llm_provider")
if llm_key:
text = _replace_config_value(text, f"{provider}_api_key", llm_key)
changes.append(f"{provider}_api_key")
if base_url:
text = _replace_config_value(text, f"{provider}_base_url", base_url)
changes.append(f"{provider}_base_url")
if model_name:
text = _replace_config_value(text, f"{provider}_model_name", model_name)
changes.append(f"{provider}_model_name")
if pexels_key:
text = _replace_config_value(text, "pexels_api_keys", [pexels_key])
changes.append("pexels_api_keys")
config_path.write_text(text, encoding="utf-8")
log("updated configuration fields: " + ", ".join(changes))
def _provider_is_ready(text: str, provider: str) -> bool:
"""Return whether a provider has enough configuration to generate."""
if provider in KEYLESS_LLM_PROVIDERS:
return True
if not _has_configured_value(
_plain_config_value(text, f"{provider}_api_key")
):
return False
if provider == CUSTOM_OPENAI_PROVIDER:
return all(
_has_configured_value(_plain_config_value(text, f"{provider}_{suffix}"))
for suffix in ("base_url", "model_name")
)
return True
def reuse_existing_llm_provider(config_path: Path) -> str:
"""
Reuse existing LLM credentials before asking the user for another key.
Keep the current provider when it is ready. Otherwise, scan configured
recommended providers in UI order and update ``llm_provider``. Credential
values are inspected in memory and are never logged.
"""
text = config_path.read_text(encoding="utf-8")
current_provider = _plain_config_value(text, "llm_provider") or "moonshot"
if _provider_is_ready(text, current_provider):
return current_provider
reusable_providers = (
*RECOMMENDED_LLM_PROVIDERS,
*ADDITIONAL_REUSABLE_PROVIDERS,
)
for provider in reusable_providers:
if _provider_is_ready(text, provider):
text = _replace_config_value(text, "llm_provider", provider)
config_path.write_text(text, encoding="utf-8")
log(f"reusing configured LLM provider: {provider}")
return provider
return current_provider
def selected_video_source(cli_args: list[str]) -> str:
"""Read the effective material source from forwarded CLI arguments."""
for index, item in enumerate(cli_args):
if item == "--video-source" and index + 1 < len(cli_args):
return cli_args[index + 1].strip().lower()
if item.startswith("--video-source="):
return item.split("=", 1)[1].strip().lower()
return "pexels"
def has_cli_option(cli_args: list[str], option: str) -> bool:
"""Return whether forwarded arguments explicitly set a CLI option."""
return any(item == option or item.startswith(f"{option}=") for item in cli_args)
def missing_config(config_path: Path, cli_args: list[str]) -> tuple[str, list[str]]:
"""Return the active provider and only the fields required by this run."""
text = config_path.read_text(encoding="utf-8")
provider = _plain_config_value(text, "llm_provider") or "moonshot"
missing: list[str] = []
if provider not in KEYLESS_LLM_PROVIDERS and not _has_configured_value(
_plain_config_value(text, f"{provider}_api_key")
):
missing.append(f"{provider}_api_key")
if provider == CUSTOM_OPENAI_PROVIDER:
for suffix in ("base_url", "model_name"):
field = f"{provider}_{suffix}"
if not _has_configured_value(_plain_config_value(text, field)):
missing.append(field)
source = selected_video_source(cli_args)
if source not in SUPPORTED_SOURCES:
raise SkillError(f"unsupported video source: {source}")
if source != "local":
value = _plain_config_value(text, f"{source}_api_keys")
if not _has_configured_value(value):
missing.append(f"{source}_api_keys")
return provider, missing
def report_missing_config(provider: str, missing: list[str]) -> int:
"""Tell the agent exactly which credentials must be requested."""
print("MPT_NEEDS_INPUT")
print(f"LLM_PROVIDER={provider}")
for field in missing:
print(f"MISSING={field}")
if any(field.endswith("_api_key") for field in missing):
print("LLM_PROVIDER_OPTIONS_BEGIN")
for provider_id, (label, url) in RECOMMENDED_LLM_PROVIDERS.items():
print(f"LLM_PROVIDER_OPTION={provider_id}|{label}|{url}")
print(
"LLM_PROVIDER_OPTION=oneapi|Other OpenAI-compatible provider|"
"requires an API key, API base URL, and model name"
)
print("LLM_PROVIDER_OPTIONS_END")
if any(field.startswith(f"{CUSTOM_OPENAI_PROVIDER}_") for field in missing):
print(
"OPENAI_COMPATIBLE_REQUIRED="
"API key, API base URL, model name"
)
if "pexels_api_keys" in missing:
print(f"PEXELS_API_KEY_URL={PEXELS_API_KEY_URL}")
print(f"PEXELS_API_KEY_HELP_URL={PEXELS_API_KEY_HELP_URL}")
print("Request only the listed values, set the environment variables, and rerun the same command.")
return NEEDS_INPUT_EXIT_CODE
def report_invalid_pexels_config() -> int:
"""Request only a new Pexels key when every configured key is rejected."""
print("MPT_NEEDS_INPUT")
print("INVALID=pexels_api_keys")
print(f"PEXELS_API_KEY_URL={PEXELS_API_KEY_URL}")
print(f"PEXELS_API_KEY_HELP_URL={PEXELS_API_KEY_HELP_URL}")
print("All configured Pexels API keys were rejected or are unavailable. Provide a new key.")
return NEEDS_INPUT_EXIT_CODE
def _validate_pexels_key(api_key: str) -> str:
"""
Return ``valid``, ``rejected``, or ``unknown`` for a Pexels key.
HTTP 401, 403, and rate-limited 429 responses make a key unusable for this
run. Network and server errors return unknown so the configuration is kept.
"""
# Curated and popular search requests may hit a public cache and return 200
# without valid authorization. My Collections is account-specific, requires
# authentication, and still returns 200 for an empty collection list.
request = urllib.request.Request(
PEXELS_VALIDATION_URL,
headers={
"Authorization": api_key,
"User-Agent": "MoneyPrinterTurbo-Agent-Skill",
},
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
return "valid" if 200 <= response.status < 300 else "unknown"
except urllib.error.HTTPError as exc:
if exc.code in {401, 403, 429}:
return "rejected"
return "unknown"
except (TimeoutError, urllib.error.URLError):
return "unknown"
def validate_pexels_config(config_path: Path, cli_args: list[str]) -> bool:
"""
Validate all Pexels keys used by the default material source.
Downstream code selects configured keys randomly. Keeping rejected keys can
cause intermittent 401 responses and missing material results. If at least
one key is verified, retain only verified keys. If validation is impossible
because of a transient network failure, keep the original configuration.
"""
if selected_video_source(cli_args) != "pexels":
return True
text = config_path.read_text(encoding="utf-8")
keys = _parse_string_list(_plain_config_value(text, "pexels_api_keys"))
if not keys:
return False
valid_keys: list[str] = []
rejected_count = 0
unknown_count = 0
for api_key in keys:
status = _validate_pexels_key(api_key)
if status == "valid":
valid_keys.append(api_key)
elif status == "rejected":
rejected_count += 1
else:
unknown_count += 1
if valid_keys:
if valid_keys != keys:
text = _replace_config_value(text, "pexels_api_keys", valid_keys)
config_path.write_text(text, encoding="utf-8")
log(
"Pexels key validation completed: "
f"valid={len(valid_keys)}, rejected={rejected_count}, "
f"unknown={unknown_count}"
)
return True
if unknown_count:
log("Pexels keys could not be verified due to a network or service error; keeping the existing configuration")
return True
log(f"Pexels key validation failed: all {rejected_count} configured keys are unusable")
return False
def result_manifest_path(root: Path) -> Path:
return root / ".agent-logs" / "moneyprinterturbo-video" / "latest-result.json"
def write_result_manifest(root: Path, payload: dict[str, object]) -> Path:
"""
Atomically write the stable result file for agents that cannot wait.
The file contains task status and result paths only, never configuration
contents, credentials, or full logs.
"""
result_path = result_manifest_path(root)
result_path.parent.mkdir(parents=True, exist_ok=True)
data = {
"updated_at": datetime.now(timezone.utc).isoformat(),
**payload,
}
unique_suffix = str(uuid.uuid4()).replace("-", "")
temp_path = result_path.with_name(
f".{result_path.name}.{os.getpid()}.{unique_suffix}.tmp"
)
temp_path.write_text(
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
)
temp_path.replace(result_path)
return result_path.resolve()
def run_checked(command: list[str], *, cwd: Path) -> None:
"""Run dependency sync quietly and show only the last 30 lines on failure."""
log("installing or verifying project dependencies with uv")
result = subprocess.run(
command,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
errors="replace",
check=False,
)
if result.returncode != 0:
output_tail = (result.stdout or "").splitlines()[-30:]
if output_tail:
print("\n".join(output_tail), file=sys.stderr)
raise SkillError(f"dependency installation failed with exit code {result.returncode}")
def generate_video(
root: Path,
subject: str,
cli_args: list[str],
) -> tuple[list[Path], Path, Path, Path]:
"""Run one traceable CLI task and return only its final video files."""
uv = shutil.which("uv")
if not uv:
raise SkillError("uv was not found; reopen the terminal or add uv to PATH")
run_checked([uv, "sync", "--frozen"], cwd=root)
task_id = str(uuid.uuid4())
task_dir = root / "storage" / "tasks" / task_id
log_dir = root / ".agent-logs" / "moneyprinterturbo-video"
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"run-{task_id}.log"
write_result_manifest(
root,
{
"status": "running",
"subject": subject,
"task_id": task_id,
"task_dir": str(task_dir.resolve()),
"log_file": str(log_path.resolve()),
"video_files": [],
},
)
voice_args = (
[]
if has_cli_option(cli_args, "--voice-name")
else ["--voice-name", DEFAULT_VOICE_NAME]
)
command = [
uv,
"run",
"python",
"cli.py",
*cli_args,
"--video-subject",
subject,
"--task-id",
task_id,
# Older CLI versions leave voice_name empty and fail during Edge TTS
# with ``Invalid voice ''``. Supply a stable Chinese voice unless the
# user has explicitly selected another voice.
*voice_args,
# A Skill request must produce a finished video. Force the final stage
# so forwarded options cannot stop at script, audio, or materials.
"--stop-at",
"video",
]
log(f"starting video generation, task ID: {task_id}")
log(f"full generation log: {log_path}")
with log_path.open("w", encoding="utf-8") as log_file:
result = subprocess.run(
command,
cwd=root,
stdout=log_file,
stderr=subprocess.STDOUT,
text=True,
check=False,
)
if result.returncode != 0:
tail = log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-30:]
if tail:
print("\n".join(tail), file=sys.stderr)
error = (
f"video generation failed with exit code {result.returncode}; "
f"log: {log_path}"
)
write_result_manifest(
root,
{
"status": "failed",
"subject": subject,
"task_id": task_id,
"task_dir": str(task_dir.resolve()),
"log_file": str(log_path.resolve()),
"video_files": [],
"error": error,
},
)
raise SkillError(error)
videos = sorted(
path.resolve()
for path in task_dir.glob("final-*.mp4")
if path.is_file() and path.stat().st_size > 0
)
if not videos:
error = f"generation completed without a valid final MP4; log: {log_path}"
write_result_manifest(
root,
{
"status": "failed",
"subject": subject,
"task_id": task_id,
"task_dir": str(task_dir.resolve()),
"log_file": str(log_path.resolve()),
"video_files": [],
"error": error,
},
)
raise SkillError(error)
result_path = write_result_manifest(
root,
{
"status": "completed",
"subject": subject,
"task_id": task_id,
"task_dir": str(task_dir.resolve()),
"log_file": str(log_path.resolve()),
"video_files": [str(video) for video in videos],
},
)
return videos, task_dir.resolve(), log_path.resolve(), result_path
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
root = args.root.expanduser().resolve()
try:
ensure_project(root)
config_path = ensure_config(root)
apply_environment_config(config_path)
reuse_existing_llm_provider(config_path)
provider, missing = missing_config(config_path, args.cli_args)
if missing:
write_result_manifest(
root,
{
"status": "needs_input",
"subject": args.subject,
"missing": missing,
},
)
return report_missing_config(provider, missing)
if not validate_pexels_config(config_path, args.cli_args):
write_result_manifest(
root,
{
"status": "needs_input",
"subject": args.subject,
"invalid": ["pexels_api_keys"],
},
)
return report_invalid_pexels_config()
videos, task_dir, log_path, result_path = generate_video(
root, args.subject, args.cli_args
)
except (OSError, SkillError, urllib.error.URLError, zipfile.BadZipFile) as exc:
print(f"MPT_ERROR={exc}", file=sys.stderr)
return 1
print("MPT_RESULT")
for video in videos:
print(f"VIDEO_FILE={video}")
print(f"TASK_DIR={task_dir}")
print(f"LOG_FILE={log_path}")
print(f"RESULT_FILE={result_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())