From 14a8f6425c001d791d500d5f55626755d1a5b745 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:48:54 +0000 Subject: [PATCH 1/6] Add PLAN.md for Threads Vietnam Video Maker transformation Agent-Logs-Url: https://github.com/thaitien280401-stack/RedditVideoMakerBot/sessions/b2183a86-2887-4db0-82aa-07d9da5aa1be Co-authored-by: thaitien280401-stack <271128961+thaitien280401-stack@users.noreply.github.com> --- PLAN.md | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..8fa8af5 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,119 @@ +# 🇻🇳 Kế Hoạch Chuyển Đổi: Reddit Video Maker → Threads Vietnam Video Maker + +## Tổng Quan +Chuyển đổi ứng dụng Reddit Video Maker Bot thành công cụ tự động tạo video từ nội dung Threads (Meta) cho thị trường Việt Nam, với khả năng tự động đăng lên TikTok, YouTube và Facebook. + +--- + +## Giai Đoạn 1: Thay Thế Reddit bằng Threads API +### 1.1 Module `threads/` - Lấy nội dung từ Threads +- Tạo `threads/__init__.py` +- Tạo `threads/threads_client.py` - Client gọi Threads API (Meta Graph API) + - Đăng nhập OAuth2 với Threads API + - Lấy danh sách bài viết trending/hot + - Lấy replies (comments) của bài viết + - Lọc nội dung theo từ khóa, độ dài, ngôn ngữ +- Cấu trúc dữ liệu trả về tương tự reddit_object: + ```python + { + "thread_url": "https://threads.net/@user/post/...", + "thread_title": "Nội dung bài viết", + "thread_id": "abc123", + "thread_author": "@username", + "is_nsfw": False, + "thread_post": "Nội dung đầy đủ", + "comments": [ + { + "comment_body": "Nội dung reply", + "comment_url": "permalink", + "comment_id": "xyz789", + "comment_author": "@user2" + } + ] + } + ``` + +### 1.2 Cập nhật Screenshot cho Threads +- Tạo `video_creation/threads_screenshot.py` + - Render HTML template theo style Threads + - Hỗ trợ giao diện dark/light mode + - Hiển thị avatar, username, verified badge + - Font hỗ trợ tiếng Việt (Unicode đầy đủ) + +--- + +## Giai Đoạn 2: Tối Ưu Cho Thị Trường Việt Nam +### 2.1 Vietnamese TTS +- Sử dụng Google Translate TTS (gTTS) với ngôn ngữ `vi` +- Hỗ trợ giọng đọc tiếng Việt tự nhiên +- Cấu hình mặc định `post_lang = "vi"` + +### 2.2 Xử Lý Văn Bản Tiếng Việt +- Cập nhật `utils/voice.py` cho tiếng Việt +- Xử lý dấu, ký tự Unicode Vietnamese +- Tối ưu ngắt câu cho TTS tiếng Việt + +--- + +## Giai Đoạn 3: Auto-Posting - Đăng Tự Động +### 3.1 Module `uploaders/` +- `uploaders/__init__.py` +- `uploaders/base_uploader.py` - Base class +- `uploaders/tiktok_uploader.py` - Đăng lên TikTok + - Sử dụng TikTok Content Posting API + - Hỗ trợ set caption, hashtags + - Schedule posting +- `uploaders/youtube_uploader.py` - Đăng lên YouTube + - Sử dụng YouTube Data API v3 + - Upload video với title, description, tags + - Set privacy (public/private/unlisted) + - Schedule publishing +- `uploaders/facebook_uploader.py` - Đăng lên Facebook + - Sử dụng Facebook Graph API + - Upload video lên Page hoặc Profile + - Set caption, scheduling + +### 3.2 Upload Manager +- `uploaders/upload_manager.py` + - Quản lý upload đồng thời nhiều platform + - Retry logic khi upload thất bại + - Logging và tracking trạng thái + +--- + +## Giai Đoạn 4: Hệ Thống Lên Lịch Tự Động +### 4.1 Module `scheduler/` +- `scheduler/__init__.py` +- `scheduler/scheduler.py` - Lên lịch tạo và đăng video + - Sử dụng APScheduler + - Cron-style scheduling + - Hỗ trợ múi giờ Việt Nam (Asia/Ho_Chi_Minh) +- `scheduler/pipeline.py` - Pipeline tự động + - Fetch content → TTS → Screenshots → Video → Upload + - Error handling và retry + - Notification khi hoàn thành + +--- + +## Giai Đoạn 5: Cập Nhật Cấu Hình & Entry Point +### 5.1 Config mới +- Cập nhật `utils/.config.template.toml` thêm sections: + - `[threads.creds]` - Threads API credentials + - `[uploaders.tiktok]` - TikTok config + - `[uploaders.youtube]` - YouTube config + - `[uploaders.facebook]` - Facebook config + - `[scheduler]` - Scheduling config + +### 5.2 Entry Point +- Cập nhật `main.py` cho workflow mới +- Hỗ trợ 3 modes: manual, auto, scheduled + +--- + +## Dependencies Mới +``` +google-api-python-client # YouTube Data API +google-auth-oauthlib # Google OAuth +apscheduler # Task scheduling +httpx # Async HTTP client (Threads API) +``` From 00a37231b8ed98ebf09a20bae332f83b8dede906 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:59:06 +0000 Subject: [PATCH 2/6] Implement Threads Vietnam Video Maker with auto-upload and scheduling - Add threads/ module: Threads API client for fetching content from Meta Threads - Add video_creation/threads_screenshot.py: Generate Threads-style images with PIL - Add uploaders/ module: YouTube, TikTok, Facebook auto-upload support - Add scheduler/ module: APScheduler-based automated pipeline - Update main.py: New entry point with manual/auto/scheduled modes - Update config template: Threads, uploaders, scheduler configuration - Update dependencies: google-api-python-client, APScheduler - Update references from Reddit to Threads across TTS and video modules - Maintain backward compatibility with --reddit flag Agent-Logs-Url: https://github.com/thaitien280401-stack/RedditVideoMakerBot/sessions/b2183a86-2887-4db0-82aa-07d9da5aa1be Co-authored-by: thaitien280401-stack <271128961+thaitien280401-stack@users.noreply.github.com> --- README.md | 161 ++++++++++--- TTS/GTTS.py | 8 +- TTS/engine_wrapper.py | 7 +- main.py | 284 ++++++++++++++++------ requirements.txt | 6 + scheduler/__init__.py | 0 scheduler/pipeline.py | 223 +++++++++++++++++ threads/__init__.py | 0 threads/threads_client.py | 245 +++++++++++++++++++ uploaders/__init__.py | 0 uploaders/base_uploader.py | 117 +++++++++ uploaders/facebook_uploader.py | 215 +++++++++++++++++ uploaders/tiktok_uploader.py | 223 +++++++++++++++++ uploaders/upload_manager.py | 137 +++++++++++ uploaders/youtube_uploader.py | 219 +++++++++++++++++ utils/.config.template.toml | 150 +++++++----- utils/videos.py | 22 +- video_creation/final_video.py | 14 +- video_creation/threads_screenshot.py | 344 +++++++++++++++++++++++++++ 19 files changed, 2211 insertions(+), 164 deletions(-) create mode 100644 scheduler/__init__.py create mode 100644 scheduler/pipeline.py create mode 100644 threads/__init__.py create mode 100644 threads/threads_client.py create mode 100644 uploaders/__init__.py create mode 100644 uploaders/base_uploader.py create mode 100644 uploaders/facebook_uploader.py create mode 100644 uploaders/tiktok_uploader.py create mode 100644 uploaders/upload_manager.py create mode 100644 uploaders/youtube_uploader.py create mode 100644 video_creation/threads_screenshot.py diff --git a/README.md b/README.md index 8042755..6b74289 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,134 @@ -# Reddit Video Maker Bot 🎥 - -All done WITHOUT video editing or asset compiling. Just pure ✨programming magic✨. - -Created by Lewis Menelaws & [TMRRW](https://tmrrwinc.ca) - - - - - - - - - - -## Video Explainer - -[![lewisthumbnail](https://user-images.githubusercontent.com/6053155/173631669-1d1b14ad-c478-4010-b57d-d79592a789f2.png) -](https://www.youtube.com/watch?v=3gjcY_00U1w) - -## Motivation 🤔 - -These videos on TikTok, YouTube and Instagram get MILLIONS of views across all platforms and require very little effort. -The only original thing being done is the editing and gathering of all materials... - -... but what if we can automate that process? 🤔 - -## Disclaimers 🚨 - -- **At the moment**, this repository won't attempt to upload this content through this bot. It will give you a file that +# 🇻🇳 Threads Video Maker Bot - Phiên Bản Việt Nam 🎥 + +Tạo video tự động từ nội dung **Threads (Meta)** và đăng lên **TikTok**, **YouTube**, **Facebook**. + +Được phát triển dựa trên nền tảng Reddit Video Maker Bot, tối ưu hóa cho thị trường Việt Nam. + +## Tính Năng Chính ✨ + +- 📱 **Threads Integration**: Lấy nội dung tự động từ Threads (Meta) thay vì Reddit +- 🎙️ **TTS Tiếng Việt**: Hỗ trợ đọc tiếng Việt qua Google Translate TTS, OpenAI, và nhiều engine khác +- 📤 **Auto-Upload**: Tự động đăng video lên TikTok, YouTube, Facebook +- ⏰ **Lên Lịch Tự Động**: Cron-based scheduling với múi giờ Việt Nam +- 🎬 **Video Chất Lượng**: Background gaming, nhạc nền lofi, subtitle overlay +- 🔄 **Pipeline Hoàn Chỉnh**: Từ lấy nội dung → TTS → Screenshot → Video → Upload + +## Cài Đặt 🛠️ + +### Yêu Cầu +- Python 3.10, 3.11 hoặc 3.12 +- FFmpeg +- Tài khoản Threads Developer (Meta) + +### Bước 1: Clone và cài đặt +```bash +git clone https://github.com/thaitien280401-stack/RedditVideoMakerBot.git +cd RedditVideoMakerBot +pip install -r requirements.txt +``` + +### Bước 2: Cấu hình +Chạy lần đầu để tạo file `config.toml`: +```bash +python main.py +``` + +Hoặc copy từ template: +```bash +cp utils/.config.template.toml config.toml +``` + +### Bước 3: Điền thông tin API +Chỉnh sửa `config.toml`: + +```toml +[threads.creds] +access_token = "YOUR_THREADS_ACCESS_TOKEN" +user_id = "YOUR_THREADS_USER_ID" + +[settings.tts] +voice_choice = "googletranslate" # Hỗ trợ tiếng Việt tốt nhất +``` + +## Sử Dụng 🚀 + +### Chế độ Manual (Mặc định) +```bash +python main.py +``` + +### Chế độ Auto (Tạo + Upload) +```bash +python main.py --mode auto +``` + +### Chế độ Scheduled (Lên lịch tự động) +```bash +python main.py --mode scheduled +``` + +### Legacy Reddit Mode +```bash +python main.py --reddit +``` + +## Cấu Hình Upload 📤 + +### YouTube +```toml +[uploaders.youtube] +enabled = true +client_id = "YOUR_GOOGLE_CLIENT_ID" +client_secret = "YOUR_GOOGLE_CLIENT_SECRET" +refresh_token = "YOUR_GOOGLE_REFRESH_TOKEN" +``` + +### TikTok +```toml +[uploaders.tiktok] +enabled = true +client_key = "YOUR_TIKTOK_CLIENT_KEY" +client_secret = "YOUR_TIKTOK_CLIENT_SECRET" +refresh_token = "YOUR_TIKTOK_REFRESH_TOKEN" +``` + +### Facebook +```toml +[uploaders.facebook] +enabled = true +page_id = "YOUR_FACEBOOK_PAGE_ID" +access_token = "YOUR_FACEBOOK_PAGE_ACCESS_TOKEN" +``` + +## Cấu Hình Scheduler ⏰ + +```toml +[scheduler] +enabled = true +cron = "0 8,14,20 * * *" # Chạy lúc 8h, 14h, 20h hàng ngày +timezone = "Asia/Ho_Chi_Minh" +max_videos_per_day = 4 +``` + +## Kiến Trúc Hệ Thống 🏗️ + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Threads API │────▶│ Video Engine │────▶│ Upload Manager │ +│ (Meta Graph) │ │ TTS + FFmpeg │ │ YT/TT/FB APIs │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + ▼ ▼ ▼ + threads/ video_creation/ uploaders/ + threads_client.py threads_screenshot.py youtube_uploader.py + voices.py tiktok_uploader.py + final_video.py facebook_uploader.py + upload_manager.py + │ + ▼ + scheduler/ + pipeline.py (APScheduler) +``` you will then have to upload manually. This is for the sake of avoiding any sort of community guideline issues. ## Requirements diff --git a/TTS/GTTS.py b/TTS/GTTS.py index 2e2d530..8fbaada 100644 --- a/TTS/GTTS.py +++ b/TTS/GTTS.py @@ -11,9 +11,15 @@ class GTTS: self.voices = [] def run(self, text, filepath, random_voice: bool = False): + # Support both Threads and Reddit config for language + lang = "en" + if "threads" in settings.config and "thread" in settings.config["threads"]: + lang = settings.config["threads"]["thread"].get("post_lang", "") or lang + elif "reddit" in settings.config and "thread" in settings.config["reddit"]: + lang = settings.config["reddit"]["thread"].get("post_lang", "") or lang tts = gTTS( text=text, - lang=settings.config["reddit"]["thread"]["post_lang"] or "en", + lang=lang, slow=False, ) tts.save(filepath) diff --git a/TTS/engine_wrapper.py b/TTS/engine_wrapper.py index 1026a6d..fd0f9da 100644 --- a/TTS/engine_wrapper.py +++ b/TTS/engine_wrapper.py @@ -179,7 +179,12 @@ class TTSEngine: def process_text(text: str, clean: bool = True): - lang = settings.config["reddit"]["thread"]["post_lang"] + # Support both Threads and Reddit config + lang = "" + if "threads" in settings.config and "thread" in settings.config["threads"]: + lang = settings.config["threads"]["thread"].get("post_lang", "") + if not lang and "reddit" in settings.config and "thread" in settings.config["reddit"]: + lang = settings.config["reddit"]["thread"].get("post_lang", "") new_text = sanitize_text(text) if clean else text if lang: print_substep("Translating Text...") diff --git a/main.py b/main.py index 742fedf..46a7f90 100755 --- a/main.py +++ b/main.py @@ -1,4 +1,25 @@ #!/usr/bin/env python +""" +Threads Video Maker Bot - Tạo video tự động từ Threads (Meta) cho thị trường Việt Nam. + +Hỗ trợ: +- Lấy nội dung từ Threads (Meta) thay vì Reddit +- TTS tiếng Việt (Google Translate, OpenAI, v.v.) +- Tự động upload lên TikTok, YouTube, Facebook +- Lên lịch tạo video tự động + +Modes: +- manual: Tạo video thủ công (mặc định) +- auto: Tạo video và tự động upload +- scheduled: Lên lịch tạo video tự động + +Usage: + python main.py # Chế độ manual + python main.py --mode auto # Tạo + upload + python main.py --mode scheduled # Lên lịch tự động + python main.py --reddit # Legacy Reddit mode +""" + import math import sys from os import name @@ -6,88 +27,155 @@ from pathlib import Path from subprocess import Popen from typing import Dict, NoReturn -from prawcore import ResponseException - -from reddit.subreddit import get_subreddit_threads from utils import settings from utils.cleanup import cleanup from utils.console import print_markdown, print_step, print_substep from utils.ffmpeg_install import ffmpeg_install from utils.id import extract_id -from utils.version import checkversion -from video_creation.background import ( - chop_background, - download_background_audio, - download_background_video, - get_background_config, -) -from video_creation.final_video import make_final_video -from video_creation.screenshot_downloader import get_screenshots_of_reddit_posts -from video_creation.voices import save_text_to_mp3 -__VERSION__ = "3.4.0" +__VERSION__ = "4.0.0" print( """ -██████╗ ███████╗██████╗ ██████╗ ██╗████████╗ ██╗ ██╗██╗██████╗ ███████╗ ██████╗ ███╗ ███╗ █████╗ ██╗ ██╗███████╗██████╗ -██╔══██╗██╔════╝██╔══██╗██╔══██╗██║╚══██╔══╝ ██║ ██║██║██╔══██╗██╔════╝██╔═══██╗ ████╗ ████║██╔══██╗██║ ██╔╝██╔════╝██╔══██╗ -██████╔╝█████╗ ██║ ██║██║ ██║██║ ██║ ██║ ██║██║██║ ██║█████╗ ██║ ██║ ██╔████╔██║███████║█████╔╝ █████╗ ██████╔╝ -██╔══██╗██╔══╝ ██║ ██║██║ ██║██║ ██║ ╚██╗ ██╔╝██║██║ ██║██╔══╝ ██║ ██║ ██║╚██╔╝██║██╔══██║██╔═██╗ ██╔══╝ ██╔══██╗ -██║ ██║███████╗██████╔╝██████╔╝██║ ██║ ╚████╔╝ ██║██████╔╝███████╗╚██████╔╝ ██║ ╚═╝ ██║██║ ██║██║ ██╗███████╗██║ ██║ -╚═╝ ╚═╝╚══════╝╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═══╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ +████████╗██╗ ██╗██████╗ ███████╗ █████╗ ██████╗ ███████╗ ██╗ ██╗██╗██████╗ ███████╗ ██████╗ +╚══██╔══╝██║ ██║██╔══██╗██╔════╝██╔══██╗██╔══██╗██╔════╝ ██║ ██║██║██╔══██╗██╔════╝██╔═══██╗ + ██║ ███████║██████╔╝█████╗ ███████║██║ ██║███████╗ ██║ ██║██║██║ ██║█████╗ ██║ ██║ + ██║ ██╔══██║██╔══██╗██╔══╝ ██╔══██║██║ ██║╚════██║ ╚██╗ ██╔╝██║██║ ██║██╔══╝ ██║ ██║ + ██║ ██║ ██║██║ ██║███████╗██║ ██║██████╔╝███████║ ╚████╔╝ ██║██████╔╝███████╗╚██████╔╝ + ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ╚══════╝ ╚═══╝ ╚═╝╚═════╝ ╚══════╝ ╚═════╝ + + ███╗ ███╗ █████╗ ██╗ ██╗███████╗██████╗ 🇻🇳 VIETNAM EDITION + ████╗ ████║██╔══██╗██║ ██╔╝██╔════╝██╔══██╗ + ██╔████╔██║███████║█████╔╝ █████╗ ██████╔╝ Powered by Threads (Meta) + ██║╚██╔╝██║██╔══██║██╔═██╗ ██╔══╝ ██╔══██╗ Auto-post: TikTok | YouTube | Facebook + ██║ ╚═╝ ██║██║ ██║██║ ██╗███████╗██║ ██║ + ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ """ ) print_markdown( - "### Thanks for using this tool! Feel free to contribute to this project on GitHub! If you have any questions, feel free to join my Discord server or submit a GitHub issue. You can find solutions to many common problems in the documentation: https://reddit-video-maker-bot.netlify.app/" + "### 🇻🇳 Threads Video Maker Bot - Phiên bản Việt Nam\n" + "Tạo video tự động từ nội dung Threads và đăng lên TikTok, YouTube, Facebook.\n" + "Tài liệu: https://github.com/thaitien280401-stack/RedditVideoMakerBot" ) -checkversion(__VERSION__) -reddit_id: str -reddit_object: Dict[str, str | list] +thread_id: str +thread_object: Dict[str, str | list] -def main(POST_ID=None) -> None: - global reddit_id, reddit_object - reddit_object = get_subreddit_threads(POST_ID) - reddit_id = extract_id(reddit_object) - print_substep(f"Thread ID is {reddit_id}", style="bold blue") - length, number_of_comments = save_text_to_mp3(reddit_object) +def main_threads(POST_ID=None) -> None: + """Pipeline chính: Lấy nội dung từ Threads → Tạo video.""" + global thread_id, thread_object + + from threads.threads_client import get_threads_posts + from video_creation.background import ( + chop_background, + download_background_audio, + download_background_video, + get_background_config, + ) + from video_creation.final_video import make_final_video + from video_creation.threads_screenshot import get_screenshots_of_threads_posts + from video_creation.voices import save_text_to_mp3 + + thread_object = get_threads_posts(POST_ID) + thread_id = extract_id(thread_object) + print_substep(f"Thread ID: {thread_id}", style="bold blue") + + length, number_of_comments = save_text_to_mp3(thread_object) length = math.ceil(length) - get_screenshots_of_reddit_posts(reddit_object, number_of_comments) + + get_screenshots_of_threads_posts(thread_object, number_of_comments) + bg_config = { "video": get_background_config("video"), "audio": get_background_config("audio"), } download_background_video(bg_config["video"]) download_background_audio(bg_config["audio"]) - chop_background(bg_config, length, reddit_object) - make_final_video(number_of_comments, length, reddit_object, bg_config) + chop_background(bg_config, length, thread_object) + make_final_video(number_of_comments, length, thread_object, bg_config) + +def main_threads_with_upload(POST_ID=None) -> None: + """Pipeline đầy đủ: Threads → Video → Upload lên các platform.""" + from scheduler.pipeline import run_pipeline + run_pipeline(POST_ID) -def run_many(times) -> None: + +def main_reddit(POST_ID=None) -> None: + """Legacy mode: Sử dụng Reddit làm nguồn nội dung.""" + from reddit.subreddit import get_subreddit_threads + from video_creation.background import ( + chop_background, + download_background_audio, + download_background_video, + get_background_config, + ) + from video_creation.final_video import make_final_video + from video_creation.screenshot_downloader import get_screenshots_of_reddit_posts + from video_creation.voices import save_text_to_mp3 + + global thread_id, thread_object + thread_object = get_subreddit_threads(POST_ID) + thread_id = extract_id(thread_object) + print_substep(f"Thread ID: {thread_id}", style="bold blue") + length, number_of_comments = save_text_to_mp3(thread_object) + length = math.ceil(length) + get_screenshots_of_reddit_posts(thread_object, number_of_comments) + bg_config = { + "video": get_background_config("video"), + "audio": get_background_config("audio"), + } + download_background_video(bg_config["video"]) + download_background_audio(bg_config["audio"]) + chop_background(bg_config, length, thread_object) + make_final_video(number_of_comments, length, thread_object, bg_config) + + +def run_many(times, use_reddit=False) -> None: + """Chạy nhiều lần tạo video.""" + main_func = main_reddit if use_reddit else main_threads for x in range(1, times + 1): - print_step( - f'on the {x}{("th", "st", "nd", "rd", "th", "th", "th", "th", "th", "th")[x % 10]} iteration of {times}' - ) - main() + print_step(f"Đang tạo video {x}/{times}...") + main_func() Popen("cls" if name == "nt" else "clear", shell=True).wait() def shutdown() -> NoReturn: - if "reddit_id" in globals(): - print_markdown("## Clearing temp files") - cleanup(reddit_id) + if "thread_id" in globals(): + print_markdown("## Đang dọn dẹp file tạm...") + cleanup(thread_id) - print("Exiting...") + print("Thoát...") sys.exit() +def parse_args(): + """Parse command line arguments.""" + import argparse + parser = argparse.ArgumentParser(description="Threads Video Maker Bot - Vietnam Edition") + parser.add_argument( + "--mode", + choices=["manual", "auto", "scheduled"], + default="manual", + help="Chế độ chạy: manual (mặc định), auto (tạo + upload), scheduled (lên lịch)", + ) + parser.add_argument( + "--reddit", + action="store_true", + help="Sử dụng Reddit thay vì Threads (legacy mode)", + ) + return parser.parse_args() + + if __name__ == "__main__": if sys.version_info.major != 3 or sys.version_info.minor not in [10, 11, 12]: print( - "Hey! Congratulations, you've made it so far (which is pretty rare with no Python 3.10). Unfortunately, this program only works on Python 3.10. Please install Python 3.10 and try again." + "Ứng dụng yêu cầu Python 3.10, 3.11 hoặc 3.12. Vui lòng cài đặt phiên bản phù hợp." ) sys.exit() + + args = parse_args() ffmpeg_install() directory = Path().absolute() config = settings.check_toml( @@ -95,42 +183,102 @@ if __name__ == "__main__": ) config is False and sys.exit() + # Kiểm tra TikTok TTS session if ( - not settings.config["settings"]["tts"]["tiktok_sessionid"] - or settings.config["settings"]["tts"]["tiktok_sessionid"] == "" + not settings.config["settings"]["tts"].get("tiktok_sessionid", "") ) and config["settings"]["tts"]["voice_choice"] == "tiktok": print_substep( - "TikTok voice requires a sessionid! Check our documentation on how to obtain one.", + "TikTok TTS cần sessionid! Xem tài liệu để biết cách lấy.", "bold red", ) sys.exit() + try: - if config["reddit"]["thread"]["post_id"]: - for index, post_id in enumerate(config["reddit"]["thread"]["post_id"].split("+")): - index += 1 - print_step( - f'on the {index}{("st" if index % 10 == 1 else ("nd" if index % 10 == 2 else ("rd" if index % 10 == 3 else "th")))} post of {len(config["reddit"]["thread"]["post_id"].split("+"))}' - ) - main(post_id) - Popen("cls" if name == "nt" else "clear", shell=True).wait() - elif config["settings"]["times_to_run"]: - run_many(config["settings"]["times_to_run"]) + if args.mode == "scheduled": + # Chế độ lên lịch tự động + print_step("🕐 Khởi động chế độ lên lịch tự động...") + from scheduler.pipeline import run_scheduled + run_scheduled() + + elif args.mode == "auto": + # Chế độ tự động: tạo + upload + print_step("🚀 Khởi động chế độ tự động (tạo + upload)...") + if args.reddit: + # Legacy Reddit mode + main_reddit() + else: + thread_config = config.get("threads", {}).get("thread", {}) + post_id = thread_config.get("post_id", "") + if post_id: + for index, pid in enumerate(post_id.split("+")): + index += 1 + print_step(f"Đang xử lý thread {index}/{len(post_id.split('+'))}...") + main_threads_with_upload(pid) + Popen("cls" if name == "nt" else "clear", shell=True).wait() + elif config["settings"]["times_to_run"]: + for i in range(config["settings"]["times_to_run"]): + print_step(f"Đang tạo video {i + 1}/{config['settings']['times_to_run']}...") + main_threads_with_upload() + else: + main_threads_with_upload() + else: - main() + # Chế độ manual (mặc định) + if args.reddit: + # Legacy Reddit mode + from prawcore import ResponseException + try: + if config["reddit"]["thread"]["post_id"]: + for index, post_id in enumerate(config["reddit"]["thread"]["post_id"].split("+")): + index += 1 + print_step(f"Đang xử lý post {index}...") + main_reddit(post_id) + Popen("cls" if name == "nt" else "clear", shell=True).wait() + elif config["settings"]["times_to_run"]: + run_many(config["settings"]["times_to_run"], use_reddit=True) + else: + main_reddit() + except ResponseException: + print_markdown("## Thông tin đăng nhập Reddit không hợp lệ") + print_markdown("Vui lòng kiểm tra config.toml") + shutdown() + else: + # Threads mode (mặc định) + thread_config = config.get("threads", {}).get("thread", {}) + post_id = thread_config.get("post_id", "") + if post_id: + for index, pid in enumerate(post_id.split("+")): + index += 1 + print_step(f"Đang xử lý thread {index}/{len(post_id.split('+'))}...") + main_threads(pid) + Popen("cls" if name == "nt" else "clear", shell=True).wait() + elif config["settings"]["times_to_run"]: + run_many(config["settings"]["times_to_run"]) + else: + main_threads() + except KeyboardInterrupt: shutdown() - except ResponseException: - print_markdown("## Invalid credentials") - print_markdown("Please check your credentials in the config.toml file") - shutdown() except Exception as err: - config["settings"]["tts"]["tiktok_sessionid"] = "REDACTED" - config["settings"]["tts"]["elevenlabs_api_key"] = "REDACTED" - config["settings"]["tts"]["openai_api_key"] = "REDACTED" + # Redact sensitive values + try: + config["settings"]["tts"]["tiktok_sessionid"] = "REDACTED" + config["settings"]["tts"]["elevenlabs_api_key"] = "REDACTED" + config["settings"]["tts"]["openai_api_key"] = "REDACTED" + if "threads" in config and "creds" in config["threads"]: + config["threads"]["creds"]["access_token"] = "REDACTED" + if "uploaders" in config: + for platform in config["uploaders"]: + for key in ["access_token", "client_secret", "refresh_token"]: + if key in config["uploaders"][platform]: + config["uploaders"][platform][key] = "REDACTED" + except (KeyError, TypeError): + pass + print_step( - f"Sorry, something went wrong with this version! Try again, and feel free to report this issue at GitHub or the Discord community.\n" - f"Version: {__VERSION__} \n" - f"Error: {err} \n" - f'Config: {config["settings"]}' + f"Đã xảy ra lỗi! Vui lòng thử lại hoặc báo lỗi trên GitHub.\n" + f"Phiên bản: {__VERSION__}\n" + f"Lỗi: {err}\n" + f'Config: {config.get("settings", {})}' ) raise err diff --git a/requirements.txt b/requirements.txt index 7aa38ee..fbd90c0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +# Core dependencies boto3==1.36.8 botocore==1.36.8 gTTS==2.5.4 @@ -19,3 +20,8 @@ transformers==4.52.4 ffmpeg-python==0.2.0 elevenlabs==1.57.0 yt-dlp==2025.10.22 + +# New dependencies for Threads Vietnam Video Maker +google-api-python-client==2.166.0 +google-auth-oauthlib==1.2.1 +APScheduler==3.11.0 diff --git a/scheduler/__init__.py b/scheduler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scheduler/pipeline.py b/scheduler/pipeline.py new file mode 100644 index 0000000..8c8d1b0 --- /dev/null +++ b/scheduler/pipeline.py @@ -0,0 +1,223 @@ +""" +Scheduler - Hệ thống lên lịch tự động tạo và đăng video. + +Sử dụng APScheduler để lên lịch các tác vụ tự động. +""" + +import math +import os +import sys +from datetime import datetime +from os import name +from pathlib import Path +from subprocess import Popen +from typing import Optional + +from utils import settings +from utils.cleanup import cleanup +from utils.console import print_markdown, print_step, print_substep +from utils.id import extract_id + + +def run_pipeline(post_id: Optional[str] = None) -> Optional[str]: + """Chạy toàn bộ pipeline tạo video từ Threads. + + Pipeline: + 1. Lấy nội dung từ Threads + 2. Tạo TTS audio + 3. Tạo screenshots + 4. Tải background video/audio + 5. Ghép video cuối cùng + 6. Upload lên các platform (nếu được cấu hình) + + Args: + post_id: ID cụ thể của thread (optional). + + Returns: + Đường dẫn file video đã tạo, hoặc None nếu thất bại. + """ + from threads.threads_client import get_threads_posts + from video_creation.background import ( + chop_background, + download_background_audio, + download_background_video, + get_background_config, + ) + from video_creation.final_video import make_final_video + from video_creation.threads_screenshot import get_screenshots_of_threads_posts + from video_creation.voices import save_text_to_mp3 + + print_step("🚀 Bắt đầu pipeline tạo video...") + + try: + # Step 1: Lấy nội dung từ Threads + print_step("📱 Bước 1: Lấy nội dung từ Threads...") + thread_object = get_threads_posts(post_id) + thread_id = extract_id(thread_object) + print_substep(f"Thread ID: {thread_id}", style="bold blue") + + # Step 2: Tạo TTS audio + print_step("🎙️ Bước 2: Tạo audio TTS...") + length, number_of_comments = save_text_to_mp3(thread_object) + length = math.ceil(length) + + # Step 3: Tạo screenshots + print_step("📸 Bước 3: Tạo hình ảnh...") + get_screenshots_of_threads_posts(thread_object, number_of_comments) + + # Step 4: Background + print_step("🎬 Bước 4: Xử lý background...") + bg_config = { + "video": get_background_config("video"), + "audio": get_background_config("audio"), + } + download_background_video(bg_config["video"]) + download_background_audio(bg_config["audio"]) + chop_background(bg_config, length, thread_object) + + # Step 5: Ghép video cuối cùng + print_step("🎥 Bước 5: Tạo video cuối cùng...") + make_final_video(number_of_comments, length, thread_object, bg_config) + + # Tìm file video đã tạo + subreddit = settings.config.get("threads", {}).get("thread", {}).get("channel_name", "threads") + results_dir = f"./results/{subreddit}" + video_path = None + if os.path.exists(results_dir): + files = sorted( + [f for f in os.listdir(results_dir) if f.endswith(".mp4")], + key=lambda x: os.path.getmtime(os.path.join(results_dir, x)), + reverse=True, + ) + if files: + video_path = os.path.join(results_dir, files[0]) + + # Step 6: Upload (nếu cấu hình) + upload_config = settings.config.get("uploaders", {}) + has_uploaders = any( + upload_config.get(p, {}).get("enabled", False) + for p in ["youtube", "tiktok", "facebook"] + ) + + if has_uploaders and video_path: + print_step("📤 Bước 6: Upload video lên các platform...") + from uploaders.upload_manager import UploadManager + + manager = UploadManager() + title = thread_object.get("thread_title", "Threads Video")[:100] + description = thread_object.get("thread_post", "")[:500] + + # Tìm thumbnail nếu có + thumbnail_path = None + thumb_candidate = f"./assets/temp/{thread_id}/thumbnail.png" + if os.path.exists(thumb_candidate): + thumbnail_path = thumb_candidate + + results = manager.upload_to_all( + video_path=video_path, + title=title, + description=description, + thumbnail_path=thumbnail_path, + ) + + print_step("📊 Kết quả upload:") + for platform, url in results.items(): + if url: + print_substep(f" ✅ {platform}: {url}", style="bold green") + else: + print_substep(f" ❌ {platform}: Thất bại", style="bold red") + + print_step("✅ Pipeline hoàn tất!") + return video_path + + except Exception as e: + print_substep(f"❌ Lỗi pipeline: {e}", style="bold red") + raise + + +def run_scheduled(): + """Chạy pipeline theo lịch trình đã cấu hình. + + Sử dụng APScheduler để lên lịch. + """ + try: + from apscheduler.schedulers.blocking import BlockingScheduler + from apscheduler.triggers.cron import CronTrigger + except ImportError: + print_substep( + "Cần cài đặt APScheduler: pip install apscheduler", + style="bold red", + ) + return + + scheduler_config = settings.config.get("scheduler", {}) + enabled = scheduler_config.get("enabled", False) + + if not enabled: + print_substep("Scheduler chưa được kích hoạt trong config!", style="bold yellow") + return + + timezone = scheduler_config.get("timezone", "Asia/Ho_Chi_Minh") + cron_expression = scheduler_config.get("cron", "0 */6 * * *") # Mặc định mỗi 6 giờ + max_videos_per_day = scheduler_config.get("max_videos_per_day", 4) + + # Parse cron expression + cron_parts = cron_expression.split() + if len(cron_parts) != 5: + print_substep("Cron expression không hợp lệ! Format: minute hour day month weekday", style="bold red") + return + + scheduler = BlockingScheduler(timezone=timezone) + + videos_today = {"count": 0, "date": datetime.now().strftime("%Y-%m-%d")} + + def scheduled_job(): + """Job được chạy theo lịch.""" + current_date = datetime.now().strftime("%Y-%m-%d") + + # Reset counter nếu sang ngày mới + if current_date != videos_today["date"]: + videos_today["count"] = 0 + videos_today["date"] = current_date + + if videos_today["count"] >= max_videos_per_day: + print_substep( + f"Đã đạt giới hạn {max_videos_per_day} video/ngày. Bỏ qua.", + style="bold yellow", + ) + return + + print_step(f"⏰ Scheduler: Bắt đầu tạo video lúc {datetime.now().strftime('%H:%M:%S')}...") + try: + result = run_pipeline() + if result: + videos_today["count"] += 1 + print_substep( + f"Video #{videos_today['count']}/{max_videos_per_day} ngày hôm nay", + style="bold blue", + ) + except Exception as e: + print_substep(f"Scheduler job thất bại: {e}", style="bold red") + + trigger = CronTrigger( + minute=cron_parts[0], + hour=cron_parts[1], + day=cron_parts[2], + month=cron_parts[3], + day_of_week=cron_parts[4], + timezone=timezone, + ) + + scheduler.add_job(scheduled_job, trigger, id="video_pipeline", replace_existing=True) + + print_step(f"📅 Scheduler đã khởi động!") + print_substep(f" Cron: {cron_expression}", style="bold blue") + print_substep(f" Timezone: {timezone}", style="bold blue") + print_substep(f" Max videos/ngày: {max_videos_per_day}", style="bold blue") + print_substep(" Nhấn Ctrl+C để dừng", style="bold yellow") + + try: + scheduler.start() + except (KeyboardInterrupt, SystemExit): + scheduler.shutdown() + print_step("Scheduler đã dừng.") diff --git a/threads/__init__.py b/threads/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/threads/threads_client.py b/threads/threads_client.py new file mode 100644 index 0000000..74ea2c8 --- /dev/null +++ b/threads/threads_client.py @@ -0,0 +1,245 @@ +""" +Threads API Client - Lấy nội dung từ Meta Threads cho thị trường Việt Nam. + +Meta Threads API sử dụng Graph API endpoint. +Docs: https://developers.facebook.com/docs/threads +""" + +import re +from typing import Dict, List, Optional + +import requests + +from utils import settings +from utils.console import print_step, print_substep +from utils.videos import check_done +from utils.voice import sanitize_text + + +THREADS_API_BASE = "https://graph.threads.net/v1.0" + + +class ThreadsClient: + """Client để tương tác với Threads API (Meta).""" + + def __init__(self): + self.access_token = settings.config["threads"]["creds"]["access_token"] + self.user_id = settings.config["threads"]["creds"]["user_id"] + self.session = requests.Session() + self.session.headers.update({ + "Authorization": f"Bearer {self.access_token}", + }) + + def _get(self, endpoint: str, params: Optional[dict] = None) -> dict: + """Make a GET request to the Threads API.""" + url = f"{THREADS_API_BASE}/{endpoint}" + if params is None: + params = {} + params["access_token"] = self.access_token + response = self.session.get(url, params=params) + response.raise_for_status() + return response.json() + + def get_user_threads(self, user_id: Optional[str] = None, limit: int = 25) -> List[dict]: + """Lấy danh sách threads của user. + + Args: + user_id: Threads user ID. Mặc định là user đã cấu hình. + limit: Số lượng threads tối đa cần lấy. + + Returns: + Danh sách các thread objects. + """ + uid = user_id or self.user_id + data = self._get(f"{uid}/threads", params={ + "fields": "id,media_type,media_url,permalink,text,timestamp,username,shortcode,is_reply,reply_audience", + "limit": limit, + }) + return data.get("data", []) + + def get_thread_replies(self, thread_id: str, limit: int = 50) -> List[dict]: + """Lấy replies (comments) của một thread. + + Args: + thread_id: ID của thread. + limit: Số lượng replies tối đa. + + Returns: + Danh sách replies. + """ + data = self._get(f"{thread_id}/replies", params={ + "fields": "id,text,timestamp,username,permalink,hide_status", + "limit": limit, + "reverse": "true", + }) + return data.get("data", []) + + def get_thread_by_id(self, thread_id: str) -> dict: + """Lấy thông tin chi tiết của một thread. + + Args: + thread_id: ID của thread. + + Returns: + Thread object. + """ + return self._get(thread_id, params={ + "fields": "id,media_type,media_url,permalink,text,timestamp,username,shortcode", + }) + + def search_threads_by_keyword(self, threads: List[dict], keywords: List[str]) -> List[dict]: + """Lọc threads theo từ khóa. + + Args: + threads: Danh sách threads. + keywords: Danh sách từ khóa cần tìm. + + Returns: + Danh sách threads chứa từ khóa. + """ + filtered = [] + for thread in threads: + text = thread.get("text", "").lower() + for keyword in keywords: + if keyword.lower() in text: + filtered.append(thread) + break + return filtered + + +def _contains_blocked_words(text: str) -> bool: + """Kiểm tra xem text có chứa từ bị chặn không.""" + blocked_words = settings.config["threads"]["thread"].get("blocked_words", "") + if not blocked_words: + return False + blocked_list = [w.strip().lower() for w in blocked_words.split(",") if w.strip()] + text_lower = text.lower() + return any(word in text_lower for word in blocked_list) + + +def get_threads_posts(POST_ID: str = None) -> dict: + """Lấy nội dung từ Threads để tạo video. + + Tương tự get_subreddit_threads() nhưng cho Threads. + + Args: + POST_ID: ID cụ thể của thread. Nếu None, lấy thread mới nhất phù hợp. + + Returns: + Dict chứa thread content và replies. + """ + print_substep("Đang kết nối với Threads API...") + + client = ThreadsClient() + content = {} + + thread_config = settings.config["threads"]["thread"] + max_comment_length = int(thread_config.get("max_comment_length", 500)) + min_comment_length = int(thread_config.get("min_comment_length", 1)) + min_comments = int(thread_config.get("min_comments", 5)) + + print_step("Đang lấy nội dung từ Threads...") + + if POST_ID: + # Lấy thread cụ thể theo ID + thread = client.get_thread_by_id(POST_ID) + else: + # Lấy threads mới nhất và chọn thread phù hợp + target_user = thread_config.get("target_user_id", "") or client.user_id + threads_list = client.get_user_threads(user_id=target_user, limit=25) + + if not threads_list: + print_substep("Không tìm thấy threads nào!", style="bold red") + raise ValueError("No threads found") + + # Lọc theo từ khóa nếu có + keywords = thread_config.get("keywords", "") + if keywords: + keyword_list = [k.strip() for k in keywords.split(",") if k.strip()] + threads_list = client.search_threads_by_keyword(threads_list, keyword_list) + + # Chọn thread phù hợp (chưa tạo video, đủ replies) + thread = None + for t in threads_list: + thread_id = t.get("id", "") + # Kiểm tra xem đã tạo video cho thread này chưa + text = t.get("text", "") + if not text or _contains_blocked_words(text): + continue + # Kiểm tra số lượng replies + try: + replies = client.get_thread_replies(thread_id, limit=min_comments + 5) + if len(replies) >= min_comments: + thread = t + break + except Exception: + continue + + if thread is None: + # Nếu không tìm được thread đủ comments, lấy thread đầu tiên + if threads_list: + thread = threads_list[0] + else: + print_substep("Không tìm thấy thread phù hợp!", style="bold red") + raise ValueError("No suitable thread found") + + thread_id = thread.get("id", "") + thread_text = thread.get("text", "") + thread_url = thread.get("permalink", f"https://www.threads.net/post/{thread.get('shortcode', '')}") + thread_username = thread.get("username", "unknown") + + print_substep(f"Video sẽ được tạo từ: {thread_text[:100]}...", style="bold green") + print_substep(f"Thread URL: {thread_url}", style="bold green") + print_substep(f"Tác giả: @{thread_username}", style="bold blue") + + content["thread_url"] = thread_url + content["thread_title"] = thread_text[:200] if len(thread_text) > 200 else thread_text + content["thread_id"] = re.sub(r"[^\w\s-]", "", thread_id) + content["thread_author"] = f"@{thread_username}" + content["is_nsfw"] = False + content["thread_post"] = thread_text + content["comments"] = [] + + if settings.config["settings"].get("storymode", False): + # Story mode - đọc toàn bộ nội dung bài viết + content["thread_post"] = thread_text + else: + # Comment mode - lấy replies + try: + replies = client.get_thread_replies(thread_id, limit=50) + except Exception as e: + print_substep(f"Lỗi khi lấy replies: {e}", style="bold red") + replies = [] + + for reply in replies: + reply_text = reply.get("text", "") + reply_username = reply.get("username", "unknown") + + if not reply_text: + continue + if reply.get("hide_status", "") == "HIDDEN": + continue + if _contains_blocked_words(reply_text): + continue + + sanitised = sanitize_text(reply_text) + if not sanitised or sanitised.strip() == "": + continue + + if len(reply_text) > max_comment_length: + continue + if len(reply_text) < min_comment_length: + continue + + content["comments"].append({ + "comment_body": reply_text, + "comment_url": reply.get("permalink", ""), + "comment_id": re.sub(r"[^\w\s-]", "", reply.get("id", "")), + "comment_author": f"@{reply_username}", + }) + + print_substep( + f"Đã lấy nội dung từ Threads thành công! ({len(content.get('comments', []))} replies)", + style="bold green", + ) + return content diff --git a/uploaders/__init__.py b/uploaders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/uploaders/base_uploader.py b/uploaders/base_uploader.py new file mode 100644 index 0000000..0405096 --- /dev/null +++ b/uploaders/base_uploader.py @@ -0,0 +1,117 @@ +""" +Base Uploader - Lớp cơ sở cho tất cả uploaders. +""" + +import os +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Optional, List + +from utils.console import print_step, print_substep + + +@dataclass +class VideoMetadata: + """Metadata cho video cần upload.""" + file_path: str + title: str + description: str = "" + tags: List[str] = field(default_factory=list) + hashtags: List[str] = field(default_factory=list) + thumbnail_path: Optional[str] = None + schedule_time: Optional[str] = None # ISO 8601 format + privacy: str = "public" # public, private, unlisted + category: str = "Entertainment" + language: str = "vi" # Vietnamese + + +class BaseUploader(ABC): + """Lớp cơ sở cho tất cả platform uploaders.""" + + platform_name: str = "Unknown" + + def __init__(self): + self._authenticated = False + + @abstractmethod + def authenticate(self) -> bool: + """Xác thực với platform API. + + Returns: + True nếu xác thực thành công. + """ + pass + + @abstractmethod + def upload(self, metadata: VideoMetadata) -> Optional[str]: + """Upload video lên platform. + + Args: + metadata: VideoMetadata chứa thông tin video. + + Returns: + URL của video đã upload, hoặc None nếu thất bại. + """ + pass + + def validate_video(self, metadata: VideoMetadata) -> bool: + """Kiểm tra video có hợp lệ trước khi upload. + + Args: + metadata: VideoMetadata cần kiểm tra. + + Returns: + True nếu hợp lệ. + """ + if not os.path.exists(metadata.file_path): + print_substep(f"[{self.platform_name}] File không tồn tại: {metadata.file_path}", style="bold red") + return False + + file_size = os.path.getsize(metadata.file_path) + if file_size == 0: + print_substep(f"[{self.platform_name}] File rỗng: {metadata.file_path}", style="bold red") + return False + + if not metadata.title: + print_substep(f"[{self.platform_name}] Thiếu tiêu đề video", style="bold red") + return False + + return True + + def safe_upload(self, metadata: VideoMetadata, max_retries: int = 3) -> Optional[str]: + """Upload video với retry logic. + + Args: + metadata: VideoMetadata chứa thông tin video. + max_retries: Số lần thử lại tối đa. + + Returns: + URL của video đã upload, hoặc None nếu thất bại. + """ + if not self.validate_video(metadata): + return None + + if not self._authenticated: + print_step(f"Đang xác thực với {self.platform_name}...") + if not self.authenticate(): + print_substep(f"Xác thực {self.platform_name} thất bại!", style="bold red") + return None + + for attempt in range(1, max_retries + 1): + try: + print_step(f"Đang upload lên {self.platform_name} (lần {attempt}/{max_retries})...") + url = self.upload(metadata) + if url: + print_substep( + f"Upload {self.platform_name} thành công! URL: {url}", + style="bold green", + ) + return url + except Exception as e: + print_substep( + f"[{self.platform_name}] Lỗi upload (lần {attempt}): {e}", + style="bold red", + ) + + print_substep(f"Upload {self.platform_name} thất bại sau {max_retries} lần thử!", style="bold red") + return None diff --git a/uploaders/facebook_uploader.py b/uploaders/facebook_uploader.py new file mode 100644 index 0000000..527b7d1 --- /dev/null +++ b/uploaders/facebook_uploader.py @@ -0,0 +1,215 @@ +""" +Facebook Uploader - Upload video lên Facebook sử dụng Graph API. + +Yêu cầu: +- Facebook Developer App +- Page Access Token (cho Page upload) hoặc User Access Token +- Permissions: publish_video, pages_manage_posts +Docs: https://developers.facebook.com/docs/video-api/guides/publishing +""" + +import json +import os +import time +from typing import Optional + +import requests + +from uploaders.base_uploader import BaseUploader, VideoMetadata +from utils import settings +from utils.console import print_substep + + +class FacebookUploader(BaseUploader): + """Upload video lên Facebook Page/Profile.""" + + platform_name = "Facebook" + + # Facebook API endpoints + GRAPH_API_BASE = "https://graph.facebook.com/v21.0" + + # Limits + MAX_DESCRIPTION_LENGTH = 63206 + MAX_TITLE_LENGTH = 255 + MAX_FILE_SIZE = 10 * 1024 * 1024 * 1024 # 10 GB + + def __init__(self): + super().__init__() + self.config = settings.config.get("uploaders", {}).get("facebook", {}) + self.access_token = None + self.page_id = None + + def authenticate(self) -> bool: + """Xác thực với Facebook Graph API. + + Sử dụng Page Access Token cho upload lên Page. + + Returns: + True nếu xác thực thành công. + """ + self.access_token = self.config.get("access_token", "") + self.page_id = self.config.get("page_id", "") + + if not self.access_token: + print_substep("Facebook: Thiếu access_token", style="bold red") + return False + + if not self.page_id: + print_substep("Facebook: Thiếu page_id", style="bold red") + return False + + # Verify token + try: + response = requests.get( + f"{self.GRAPH_API_BASE}/me", + params={"access_token": self.access_token}, + timeout=15, + ) + response.raise_for_status() + data = response.json() + + if "id" in data: + self._authenticated = True + print_substep(f"Facebook: Xác thực thành công (Page: {data.get('name', self.page_id)}) ✅", style="bold green") + return True + else: + print_substep("Facebook: Token không hợp lệ", style="bold red") + return False + + except Exception as e: + print_substep(f"Facebook: Lỗi xác thực - {e}", style="bold red") + return False + + def upload(self, metadata: VideoMetadata) -> Optional[str]: + """Upload video lên Facebook Page. + + Sử dụng Resumable Upload API cho file lớn. + + Args: + metadata: VideoMetadata chứa thông tin video. + + Returns: + URL video trên Facebook, hoặc None nếu thất bại. + """ + if not self.access_token or not self.page_id: + return None + + file_size = os.path.getsize(metadata.file_path) + + title = metadata.title[:self.MAX_TITLE_LENGTH] + description = self._build_description(metadata) + + # Step 1: Initialize upload session + try: + init_response = requests.post( + f"{self.GRAPH_API_BASE}/{self.page_id}/videos", + data={ + "upload_phase": "start", + "file_size": file_size, + "access_token": self.access_token, + }, + timeout=30, + ) + init_response.raise_for_status() + init_data = init_response.json() + + upload_session_id = init_data.get("upload_session_id", "") + video_id = init_data.get("video_id", "") + + if not upload_session_id: + print_substep("Facebook: Không thể khởi tạo upload session", style="bold red") + return None + + except Exception as e: + print_substep(f"Facebook: Lỗi khởi tạo upload - {e}", style="bold red") + return None + + # Step 2: Upload video chunks + try: + chunk_size = 4 * 1024 * 1024 # 4 MB chunks + start_offset = 0 + + with open(metadata.file_path, "rb") as video_file: + while start_offset < file_size: + chunk = video_file.read(chunk_size) + transfer_response = requests.post( + f"{self.GRAPH_API_BASE}/{self.page_id}/videos", + data={ + "upload_phase": "transfer", + "upload_session_id": upload_session_id, + "start_offset": start_offset, + "access_token": self.access_token, + }, + files={"video_file_chunk": ("chunk", chunk, "application/octet-stream")}, + timeout=120, + ) + transfer_response.raise_for_status() + transfer_data = transfer_response.json() + + start_offset = int(transfer_data.get("start_offset", file_size)) + end_offset = int(transfer_data.get("end_offset", file_size)) + + if start_offset >= file_size: + break + + except Exception as e: + print_substep(f"Facebook: Lỗi upload file - {e}", style="bold red") + return None + + # Step 3: Finish upload + try: + finish_data = { + "upload_phase": "finish", + "upload_session_id": upload_session_id, + "access_token": self.access_token, + "title": title, + "description": description[:self.MAX_DESCRIPTION_LENGTH], + } + + if metadata.schedule_time: + finish_data["scheduled_publish_time"] = metadata.schedule_time + finish_data["published"] = "false" + + if metadata.thumbnail_path and os.path.exists(metadata.thumbnail_path): + with open(metadata.thumbnail_path, "rb") as thumb: + finish_response = requests.post( + f"{self.GRAPH_API_BASE}/{self.page_id}/videos", + data=finish_data, + files={"thumb": thumb}, + timeout=60, + ) + else: + finish_response = requests.post( + f"{self.GRAPH_API_BASE}/{self.page_id}/videos", + data=finish_data, + timeout=60, + ) + + finish_response.raise_for_status() + finish_result = finish_response.json() + + if finish_result.get("success", False): + video_url = f"https://www.facebook.com/{self.page_id}/videos/{video_id}" + return video_url + else: + print_substep("Facebook: Upload hoàn tất nhưng không thành công", style="bold red") + return None + + except Exception as e: + print_substep(f"Facebook: Lỗi kết thúc upload - {e}", style="bold red") + return None + + def _build_description(self, metadata: VideoMetadata) -> str: + """Tạo description cho video Facebook.""" + parts = [] + if metadata.description: + parts.append(metadata.description) + + if metadata.hashtags: + hashtag_str = " ".join(f"#{tag}" for tag in metadata.hashtags) + parts.append(hashtag_str) + + parts.append("") + parts.append("🎬 Video được tạo tự động bởi Threads Video Maker Bot") + + return "\n".join(parts) diff --git a/uploaders/tiktok_uploader.py b/uploaders/tiktok_uploader.py new file mode 100644 index 0000000..561c44c --- /dev/null +++ b/uploaders/tiktok_uploader.py @@ -0,0 +1,223 @@ +""" +TikTok Uploader - Upload video lên TikTok sử dụng Content Posting API. + +Yêu cầu: +- TikTok Developer App +- Content Posting API access +- OAuth2 access token +Docs: https://developers.tiktok.com/doc/content-posting-api-get-started +""" + +import json +import os +import time +from typing import Optional + +import requests + +from uploaders.base_uploader import BaseUploader, VideoMetadata +from utils import settings +from utils.console import print_substep + + +class TikTokUploader(BaseUploader): + """Upload video lên TikTok.""" + + platform_name = "TikTok" + + # TikTok API endpoints + API_BASE = "https://open.tiktokapis.com/v2" + TOKEN_URL = "https://open.tiktokapis.com/v2/oauth/token/" + + # Limits + MAX_CAPTION_LENGTH = 2200 + MAX_FILE_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB + MIN_DURATION = 3 # seconds + MAX_DURATION = 600 # 10 minutes + + def __init__(self): + super().__init__() + self.config = settings.config.get("uploaders", {}).get("tiktok", {}) + self.access_token = None + + def authenticate(self) -> bool: + """Xác thực với TikTok API sử dụng refresh token. + + Returns: + True nếu xác thực thành công. + """ + client_key = self.config.get("client_key", "") + client_secret = self.config.get("client_secret", "") + refresh_token = self.config.get("refresh_token", "") + + if not all([client_key, client_secret, refresh_token]): + print_substep( + "TikTok: Thiếu credentials (client_key, client_secret, refresh_token)", + style="bold red", + ) + return False + + try: + response = requests.post(self.TOKEN_URL, json={ + "client_key": client_key, + "client_secret": client_secret, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + }, timeout=30) + response.raise_for_status() + + token_data = response.json() + self.access_token = token_data.get("data", {}).get("access_token", "") + + if self.access_token: + self._authenticated = True + print_substep("TikTok: Xác thực thành công! ✅", style="bold green") + return True + else: + print_substep("TikTok: Không lấy được access token", style="bold red") + return False + + except Exception as e: + print_substep(f"TikTok: Lỗi xác thực - {e}", style="bold red") + return False + + def upload(self, metadata: VideoMetadata) -> Optional[str]: + """Upload video lên TikTok sử dụng Content Posting API. + + Flow: + 1. Initialize upload → get upload_url + 2. Upload video file to upload_url + 3. Publish video + + Args: + metadata: VideoMetadata chứa thông tin video. + + Returns: + URL video trên TikTok, hoặc None nếu thất bại. + """ + if not self.access_token: + return None + + file_size = os.path.getsize(metadata.file_path) + if file_size > self.MAX_FILE_SIZE: + print_substep(f"TikTok: File quá lớn ({file_size} bytes)", style="bold red") + return None + + # Build caption + caption = self._build_caption(metadata) + + # Step 1: Initialize upload + headers = { + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json; charset=UTF-8", + } + + init_body = { + "post_info": { + "title": caption, + "privacy_level": self._map_privacy(metadata.privacy), + "disable_duet": False, + "disable_comment": False, + "disable_stitch": False, + }, + "source_info": { + "source": "FILE_UPLOAD", + "video_size": file_size, + "chunk_size": file_size, # Single chunk upload + "total_chunk_count": 1, + }, + } + + if metadata.schedule_time: + init_body["post_info"]["schedule_time"] = metadata.schedule_time + + try: + init_response = requests.post( + f"{self.API_BASE}/post/publish/inbox/video/init/", + headers=headers, + json=init_body, + timeout=30, + ) + init_response.raise_for_status() + init_data = init_response.json() + + publish_id = init_data.get("data", {}).get("publish_id", "") + upload_url = init_data.get("data", {}).get("upload_url", "") + + if not upload_url: + print_substep("TikTok: Không lấy được upload URL", style="bold red") + return None + + except Exception as e: + print_substep(f"TikTok: Lỗi khởi tạo upload - {e}", style="bold red") + return None + + # Step 2: Upload video file + try: + with open(metadata.file_path, "rb") as video_file: + upload_headers = { + "Content-Type": "video/mp4", + "Content-Length": str(file_size), + "Content-Range": f"bytes 0-{file_size - 1}/{file_size}", + } + upload_response = requests.put( + upload_url, + headers=upload_headers, + data=video_file, + timeout=600, + ) + upload_response.raise_for_status() + + except Exception as e: + print_substep(f"TikTok: Lỗi upload file - {e}", style="bold red") + return None + + # Step 3: Check publish status + status_url = f"{self.API_BASE}/post/publish/status/fetch/" + for attempt in range(10): + try: + status_response = requests.post( + status_url, + headers=headers, + json={"publish_id": publish_id}, + timeout=30, + ) + status_data = status_response.json() + status = status_data.get("data", {}).get("status", "") + + if status == "PUBLISH_COMPLETE": + print_substep("TikTok: Upload thành công! ✅", style="bold green") + return f"https://www.tiktok.com/@user/video/{publish_id}" + elif status == "FAILED": + reason = status_data.get("data", {}).get("fail_reason", "Unknown") + print_substep(f"TikTok: Upload thất bại - {reason}", style="bold red") + return None + + time.sleep(5) # Wait 5 seconds before checking again + except Exception: + time.sleep(5) + + print_substep("TikTok: Upload timeout", style="bold yellow") + return None + + def _build_caption(self, metadata: VideoMetadata) -> str: + """Tạo caption cho video TikTok.""" + parts = [] + if metadata.title: + parts.append(metadata.title) + if metadata.hashtags: + hashtag_str = " ".join(f"#{tag}" for tag in metadata.hashtags) + parts.append(hashtag_str) + caption = " ".join(parts) + return caption[:self.MAX_CAPTION_LENGTH] + + @staticmethod + def _map_privacy(privacy: str) -> str: + """Map privacy setting to TikTok format.""" + mapping = { + "public": "PUBLIC_TO_EVERYONE", + "private": "SELF_ONLY", + "friends": "MUTUAL_FOLLOW_FRIENDS", + "unlisted": "SELF_ONLY", + } + return mapping.get(privacy, "PUBLIC_TO_EVERYONE") diff --git a/uploaders/upload_manager.py b/uploaders/upload_manager.py new file mode 100644 index 0000000..e6c46e4 --- /dev/null +++ b/uploaders/upload_manager.py @@ -0,0 +1,137 @@ +""" +Upload Manager - Quản lý upload video lên nhiều platform cùng lúc. +""" + +import os +from typing import Dict, List, Optional + +from uploaders.base_uploader import BaseUploader, VideoMetadata +from uploaders.youtube_uploader import YouTubeUploader +from uploaders.tiktok_uploader import TikTokUploader +from uploaders.facebook_uploader import FacebookUploader +from utils import settings +from utils.console import print_step, print_substep + + +class UploadManager: + """Quản lý upload video lên nhiều platform.""" + + PLATFORM_MAP = { + "youtube": YouTubeUploader, + "tiktok": TikTokUploader, + "facebook": FacebookUploader, + } + + def __init__(self): + self.uploaders: Dict[str, BaseUploader] = {} + self._init_uploaders() + + def _init_uploaders(self): + """Khởi tạo uploaders dựa trên cấu hình.""" + upload_config = settings.config.get("uploaders", {}) + + for platform_name, uploader_class in self.PLATFORM_MAP.items(): + platform_config = upload_config.get(platform_name, {}) + if platform_config.get("enabled", False): + self.uploaders[platform_name] = uploader_class() + print_substep(f"Đã kích hoạt uploader: {platform_name}", style="bold blue") + + def upload_to_all( + self, + video_path: str, + title: str, + description: str = "", + tags: Optional[List[str]] = None, + hashtags: Optional[List[str]] = None, + thumbnail_path: Optional[str] = None, + schedule_time: Optional[str] = None, + privacy: str = "public", + ) -> Dict[str, Optional[str]]: + """Upload video lên tất cả platform đã cấu hình. + + Args: + video_path: Đường dẫn file video. + title: Tiêu đề video. + description: Mô tả video. + tags: Danh sách tags. + hashtags: Danh sách hashtags. + thumbnail_path: Đường dẫn thumbnail. + schedule_time: Thời gian lên lịch (ISO 8601). + privacy: Quyền riêng tư (public/private/unlisted). + + Returns: + Dict mapping platform name -> video URL (hoặc None nếu thất bại). + """ + if not self.uploaders: + print_substep("Không có uploader nào được kích hoạt!", style="bold yellow") + return {} + + metadata = VideoMetadata( + file_path=video_path, + title=title, + description=description, + tags=tags or [], + hashtags=hashtags or self._default_hashtags(), + thumbnail_path=thumbnail_path, + schedule_time=schedule_time, + privacy=privacy, + language="vi", + ) + + results = {} + print_step(f"Đang upload video lên {len(self.uploaders)} platform...") + + for platform_name, uploader in self.uploaders.items(): + print_step(f"📤 Đang upload lên {platform_name}...") + url = uploader.safe_upload(metadata) + results[platform_name] = url + + # Summary + print_step("📊 Kết quả upload:") + success_count = 0 + for platform, url in results.items(): + if url: + print_substep(f" ✅ {platform}: {url}", style="bold green") + success_count += 1 + else: + print_substep(f" ❌ {platform}: Thất bại", style="bold red") + + print_substep( + f"Upload hoàn tất: {success_count}/{len(results)} platform thành công", + style="bold blue", + ) + + return results + + def upload_to_platform( + self, + platform_name: str, + metadata: VideoMetadata, + ) -> Optional[str]: + """Upload video lên một platform cụ thể. + + Args: + platform_name: Tên platform. + metadata: VideoMetadata chứa thông tin video. + + Returns: + URL video, hoặc None nếu thất bại. + """ + if platform_name not in self.uploaders: + print_substep(f"Platform '{platform_name}' chưa được kích hoạt!", style="bold red") + return None + + return self.uploaders[platform_name].safe_upload(metadata) + + @staticmethod + def _default_hashtags() -> List[str]: + """Hashtags mặc định cho thị trường Việt Nam.""" + return [ + "threads", + "viral", + "vietnam", + "trending", + "foryou", + "fyp", + "threadsvn", + ] diff --git a/uploaders/youtube_uploader.py b/uploaders/youtube_uploader.py new file mode 100644 index 0000000..332e837 --- /dev/null +++ b/uploaders/youtube_uploader.py @@ -0,0 +1,219 @@ +""" +YouTube Uploader - Upload video lên YouTube sử dụng YouTube Data API v3. + +Yêu cầu: +- Google API credentials (OAuth2) +- YouTube Data API v3 enabled +- Scopes: https://www.googleapis.com/auth/youtube.upload +""" + +import os +import json +import time +from typing import Optional + +import requests + +from uploaders.base_uploader import BaseUploader, VideoMetadata +from utils import settings +from utils.console import print_substep + + +class YouTubeUploader(BaseUploader): + """Upload video lên YouTube.""" + + platform_name = "YouTube" + + # YouTube API endpoints + UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3/videos" + VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos" + TOKEN_URL = "https://oauth2.googleapis.com/token" + + # Limits + MAX_TITLE_LENGTH = 100 + MAX_DESCRIPTION_LENGTH = 5000 + MAX_TAGS = 500 # Total characters for all tags + MAX_FILE_SIZE = 256 * 1024 * 1024 * 1024 # 256 GB + + def __init__(self): + super().__init__() + self.config = settings.config.get("uploaders", {}).get("youtube", {}) + self.access_token = None + + def authenticate(self) -> bool: + """Xác thực với YouTube API sử dụng refresh token. + + Cấu hình cần có: + - client_id + - client_secret + - refresh_token (lấy từ OAuth2 flow) + + Returns: + True nếu xác thực thành công. + """ + client_id = self.config.get("client_id", "") + client_secret = self.config.get("client_secret", "") + refresh_token = self.config.get("refresh_token", "") + + if not all([client_id, client_secret, refresh_token]): + print_substep( + "YouTube: Thiếu credentials (client_id, client_secret, refresh_token)", + style="bold red", + ) + return False + + try: + response = requests.post(self.TOKEN_URL, data={ + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + }, timeout=30) + response.raise_for_status() + + token_data = response.json() + self.access_token = token_data["access_token"] + self._authenticated = True + print_substep("YouTube: Xác thực thành công! ✅", style="bold green") + return True + + except Exception as e: + print_substep(f"YouTube: Lỗi xác thực - {e}", style="bold red") + return False + + def upload(self, metadata: VideoMetadata) -> Optional[str]: + """Upload video lên YouTube. + + Args: + metadata: VideoMetadata chứa thông tin video. + + Returns: + URL video trên YouTube, hoặc None nếu thất bại. + """ + if not self.access_token: + return None + + title = metadata.title[:self.MAX_TITLE_LENGTH] + description = self._build_description(metadata) + tags = metadata.tags or [] + + # Thêm hashtags vào description + if metadata.hashtags: + hashtag_str = " ".join(f"#{tag}" for tag in metadata.hashtags) + description = f"{description}\n\n{hashtag_str}" + + # Video metadata + video_metadata = { + "snippet": { + "title": title, + "description": description[:self.MAX_DESCRIPTION_LENGTH], + "tags": tags, + "categoryId": self._get_category_id(metadata.category), + "defaultLanguage": metadata.language, + "defaultAudioLanguage": metadata.language, + }, + "status": { + "privacyStatus": metadata.privacy, + "selfDeclaredMadeForKids": False, + }, + } + + # Schedule publish time + if metadata.schedule_time and metadata.privacy != "public": + video_metadata["status"]["publishAt"] = metadata.schedule_time + video_metadata["status"]["privacyStatus"] = "private" + + headers = { + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json", + } + + # Step 1: Initiate resumable upload + params = { + "uploadType": "resumable", + "part": "snippet,status", + } + + init_response = requests.post( + self.UPLOAD_URL, + headers=headers, + params=params, + json=video_metadata, + timeout=30, + ) + init_response.raise_for_status() + + upload_url = init_response.headers.get("Location") + if not upload_url: + print_substep("YouTube: Không thể khởi tạo upload session", style="bold red") + return None + + # Step 2: Upload video file + file_size = os.path.getsize(metadata.file_path) + with open(metadata.file_path, "rb") as video_file: + upload_response = requests.put( + upload_url, + headers={ + "Content-Type": "video/mp4", + "Content-Length": str(file_size), + }, + data=video_file, + timeout=600, # 10 minutes timeout for large files + ) + upload_response.raise_for_status() + + video_data = upload_response.json() + video_id = video_data.get("id", "") + + if not video_id: + print_substep("YouTube: Upload thành công nhưng không lấy được video ID", style="bold yellow") + return None + + # Step 3: Upload thumbnail if available + if metadata.thumbnail_path and os.path.exists(metadata.thumbnail_path): + self._upload_thumbnail(video_id, metadata.thumbnail_path) + + video_url = f"https://www.youtube.com/watch?v={video_id}" + return video_url + + def _upload_thumbnail(self, video_id: str, thumbnail_path: str): + """Upload thumbnail cho video.""" + try: + url = f"https://www.googleapis.com/upload/youtube/v3/thumbnails/set" + with open(thumbnail_path, "rb") as thumb_file: + response = requests.post( + url, + headers={"Authorization": f"Bearer {self.access_token}"}, + params={"videoId": video_id}, + files={"media": thumb_file}, + timeout=60, + ) + response.raise_for_status() + print_substep("YouTube: Đã upload thumbnail ✅", style="bold green") + except Exception as e: + print_substep(f"YouTube: Lỗi upload thumbnail - {e}", style="bold yellow") + + def _build_description(self, metadata: VideoMetadata) -> str: + """Tạo description cho video YouTube.""" + parts = [] + if metadata.description: + parts.append(metadata.description) + parts.append("") + parts.append("🎬 Video được tạo tự động bởi Threads Video Maker Bot") + parts.append(f"🌐 Ngôn ngữ: Tiếng Việt") + return "\n".join(parts) + + @staticmethod + def _get_category_id(category: str) -> str: + """Map category name to YouTube category ID.""" + categories = { + "Entertainment": "24", + "People & Blogs": "22", + "Comedy": "23", + "Education": "27", + "Science & Technology": "28", + "News & Politics": "25", + "Gaming": "20", + "Music": "10", + } + return categories.get(category, "24") diff --git a/utils/.config.template.toml b/utils/.config.template.toml index 9b13657..0ac45e5 100644 --- a/utils/.config.template.toml +++ b/utils/.config.template.toml @@ -1,63 +1,107 @@ -[reddit.creds] -client_id = { optional = false, nmin = 12, nmax = 30, explanation = "The ID of your Reddit app of SCRIPT type", example = "fFAGRNJru1FTz70BzhT3Zg", regex = "^[-a-zA-Z0-9._~+/]+=*$", input_error = "The client ID can only contain printable characters.", oob_error = "The ID should be over 12 and under 30 characters, double check your input." } -client_secret = { optional = false, nmin = 20, nmax = 40, explanation = "The SECRET of your Reddit app of SCRIPT type", example = "fFAGRNJru1FTz70BzhT3Zg", regex = "^[-a-zA-Z0-9._~+/]+=*$", input_error = "The client ID can only contain printable characters.", oob_error = "The secret should be over 20 and under 40 characters, double check your input." } -username = { optional = false, nmin = 3, nmax = 20, explanation = "The username of your reddit account", example = "JasonLovesDoggo", regex = "^[-_0-9a-zA-Z]+$", oob_error = "A username HAS to be between 3 and 20 characters" } -password = { optional = false, nmin = 8, explanation = "The password of your reddit account", example = "fFAGRNJru1FTz70BzhT3Zg", oob_error = "Password too short" } -2fa = { optional = true, type = "bool", options = [true, false, ], default = false, explanation = "Whether you have Reddit 2FA enabled, Valid options are True and False", example = true } +# ===== THREADS CONFIGURATION (thay thế Reddit cho thị trường Việt Nam) ===== +[threads.creds] +access_token = { optional = false, nmin = 10, explanation = "Threads API access token (lấy từ Meta Developer Portal)", example = "THR_abc123def456" } +user_id = { optional = false, nmin = 1, explanation = "Threads user ID của bạn", example = "12345678" } -[reddit.thread] -random = { optional = true, options = [true, false, ], default = false, type = "bool", explanation = "If set to no, it will ask you a thread link to extract the thread, if yes it will randomize it. Default: 'False'", example = "True" } -subreddit = { optional = false, regex = "[_0-9a-zA-Z\\+]+$", nmin = 3, explanation = "What subreddit to pull posts from, the name of the sub, not the URL. You can have multiple subreddits, add an + with no spaces.", example = "AskReddit+Redditdev", oob_error = "A subreddit name HAS to be between 3 and 20 characters" } -post_id = { optional = true, default = "", regex = "^((?!://|://)[+a-zA-Z0-9])*$", explanation = "Used if you want to use a specific post.", example = "urdtfx" } -max_comment_length = { default = 500, optional = false, nmin = 10, nmax = 10000, type = "int", explanation = "max number of characters a comment can have. default is 500", example = 500, oob_error = "the max comment length should be between 10 and 10000" } -min_comment_length = { default = 1, optional = true, nmin = 0, nmax = 10000, type = "int", explanation = "min_comment_length number of characters a comment can have. default is 0", example = 50, oob_error = "the max comment length should be between 1 and 100" } -post_lang = { default = "", optional = true, explanation = "The language you would like to translate to.", example = "es-cr", options = ['','af', 'ak', 'am', 'ar', 'as', 'ay', 'az', 'be', 'bg', 'bho', 'bm', 'bn', 'bs', 'ca', 'ceb', 'ckb', 'co', 'cs', 'cy', 'da', 'de', 'doi', 'dv', 'ee', 'el', 'en', 'en-US', 'eo', 'es', 'et', 'eu', 'fa', 'fi', 'fr', 'fy', 'ga', 'gd', 'gl', 'gn', 'gom', 'gu', 'ha', 'haw', 'hi', 'hmn', 'hr', 'ht', 'hu', 'hy', 'id', 'ig', 'ilo', 'is', 'it', 'iw', 'ja', 'jw', 'ka', 'kk', 'km', 'kn', 'ko', 'kri', 'ku', 'ky', 'la', 'lb', 'lg', 'ln', 'lo', 'lt', 'lus', 'lv', 'mai', 'mg', 'mi', 'mk', 'ml', 'mn', 'mni-Mtei', 'mr', 'ms', 'mt', 'my', 'ne', 'nl', 'no', 'nso', 'ny', 'om', 'or', 'pa', 'pl', 'ps', 'pt', 'qu', 'ro', 'ru', 'rw', 'sa', 'sd', 'si', 'sk', 'sl', 'sm', 'sn', 'so', 'sq', 'sr', 'st', 'su', 'sv', 'sw', 'ta', 'te', 'tg', 'th', 'ti', 'tk', 'tl', 'tr', 'ts', 'tt', 'ug', 'uk', 'ur', 'uz', 'vi', 'xh', 'yi', 'yo', 'zh-CN', 'zh-TW', 'zu'] } -min_comments = { default = 20, optional = false, nmin = 10, type = "int", explanation = "The minimum number of comments a post should have to be included. default is 20", example = 29, oob_error = "the minimum number of comments should be between 15 and 999999" } -blocked_words = { optional = true, default = "", type = "str", explanation = "Comma-separated list of words/phrases. Posts and comments containing any of these will be skipped.", example = "nsfw, spoiler, politics" } +[threads.thread] +target_user_id = { optional = true, default = "", explanation = "ID user muốn lấy threads. Để trống dùng user của bạn.", example = "87654321" } +post_id = { optional = true, default = "", explanation = "ID cụ thể của thread. Để trống để tự động chọn.", example = "18050000000000000" } +keywords = { optional = true, default = "", type = "str", explanation = "Từ khóa lọc threads, phân cách bằng dấu phẩy.", example = "viral, trending, hài hước" } +max_comment_length = { default = 500, optional = false, nmin = 10, nmax = 10000, type = "int", explanation = "Độ dài tối đa reply (ký tự). Mặc định: 500", example = 500, oob_error = "Phải trong khoảng 10-10000" } +min_comment_length = { default = 1, optional = true, nmin = 0, nmax = 10000, type = "int", explanation = "Độ dài tối thiểu reply. Mặc định: 1", example = 10 } +post_lang = { default = "vi", optional = true, explanation = "Ngôn ngữ. Mặc định: vi (Tiếng Việt)", example = "vi", options = ['vi', 'en', 'zh-CN', 'ja', 'ko', 'th', ''] } +min_comments = { default = 5, optional = false, nmin = 1, type = "int", explanation = "Số replies tối thiểu. Mặc định: 5", example = 5 } +blocked_words = { optional = true, default = "", type = "str", explanation = "Từ bị chặn, phân cách bằng dấu phẩy.", example = "spam, quảng cáo" } +channel_name = { optional = true, default = "Threads Vietnam", example = "Threads VN Stories", explanation = "Tên kênh hiển thị trên video" } [ai] -ai_similarity_enabled = {optional = true, option = [true, false], default = false, type = "bool", explanation = "Threads read from Reddit are sorted based on their similarity to the keywords given below"} -ai_similarity_keywords = {optional = true, type="str", example= 'Elon Musk, Twitter, Stocks', explanation = "Every keyword or even sentence, seperated with comma, is used to sort the reddit threads based on similarity"} +ai_similarity_enabled = { optional = true, option = [true, false], default = false, type = "bool", explanation = "Sắp xếp threads theo độ tương đồng với từ khóa" } +ai_similarity_keywords = { optional = true, type = "str", example = "viral, trending, hài hước", explanation = "Từ khóa sắp xếp threads theo độ tương đồng" } [settings] -allow_nsfw = { optional = false, type = "bool", default = false, example = false, options = [true, false, ], explanation = "Whether to allow NSFW content, True or False" } -theme = { optional = false, default = "dark", example = "light", options = ["dark", "light", "transparent", ], explanation = "Sets the Reddit theme, either LIGHT or DARK. For story mode you can also use a transparent background." } -times_to_run = { optional = false, default = 1, example = 2, explanation = "Used if you want to run multiple times. Set to an int e.g. 4 or 29 or 1", type = "int", nmin = 1, oob_error = "It's very hard to run something less than once." } -opacity = { optional = false, default = 0.9, example = 0.8, explanation = "Sets the opacity of the comments when overlayed over the background", type = "float", nmin = 0, nmax = 1, oob_error = "The opacity HAS to be between 0 and 1", input_error = "The opacity HAS to be a decimal number between 0 and 1" } -#transition = { optional = true, default = 0.2, example = 0.2, explanation = "Sets the transition time (in seconds) between the comments. Set to 0 if you want to disable it.", type = "float", nmin = 0, nmax = 2, oob_error = "The transition HAS to be between 0 and 2", input_error = "The opacity HAS to be a decimal number between 0 and 2" } -storymode = { optional = true, type = "bool", default = false, example = false, options = [true, false,], explanation = "Only read out title and post content, great for subreddits with stories" } -storymodemethod= { optional = true, default = 1, example = 1, explanation = "Style that's used for the storymode. Set to 0 for single picture display in whole video, set to 1 for fancy looking video ", type = "int", nmin = 0, oob_error = "It's very hard to run something less than once.", options = [0, 1] } -storymode_max_length = { optional = true, default = 1000, example = 1000, explanation = "Max length of the storymode video in characters. 200 characters are approximately 50 seconds.", type = "int", nmin = 1, oob_error = "It's very hard to make a video under a second." } -resolution_w = { optional = false, default = 1080, example = 1440, explantation = "Sets the width in pixels of the final video" } -resolution_h = { optional = false, default = 1920, example = 2560, explantation = "Sets the height in pixels of the final video" } -zoom = { optional = true, default = 1, example = 1.1, explanation = "Sets the browser zoom level. Useful if you want the text larger.", type = "float", nmin = 0.1, nmax = 2, oob_error = "The text is really difficult to read at a zoom level higher than 2" } -channel_name = { optional = true, default = "Reddit Tales", example = "Reddit Stories", explanation = "Sets the channel name for the video" } +allow_nsfw = { optional = false, type = "bool", default = false, example = false, options = [true, false], explanation = "Cho phép nội dung NSFW" } +theme = { optional = false, default = "dark", example = "dark", options = ["dark", "light"], explanation = "Giao diện Threads: DARK hoặc LIGHT" } +times_to_run = { optional = false, default = 1, example = 2, explanation = "Số video tạo mỗi lần chạy", type = "int", nmin = 1, oob_error = "Phải >= 1" } +opacity = { optional = false, default = 0.9, example = 0.8, explanation = "Độ trong suốt hình ảnh trên video", type = "float", nmin = 0, nmax = 1, oob_error = "Phải trong khoảng 0-1" } +storymode = { optional = true, type = "bool", default = false, example = false, options = [true, false], explanation = "Chế độ đọc truyện" } +storymodemethod = { optional = true, default = 1, example = 1, type = "int", nmin = 0, options = [0, 1], explanation = "Kiểu story mode. 0: 1 hình, 1: nhiều hình" } +storymode_max_length = { optional = true, default = 1000, example = 1000, type = "int", nmin = 1, explanation = "Độ dài tối đa story (ký tự)" } +resolution_w = { optional = false, default = 1080, example = 1080, explantation = "Chiều rộng video (pixels)" } +resolution_h = { optional = false, default = 1920, example = 1920, explantation = "Chiều cao video (pixels)" } +zoom = { optional = true, default = 1, example = 1.1, type = "float", nmin = 0.1, nmax = 2, explanation = "Mức zoom text" } +channel_name = { optional = true, default = "Threads Vietnam", example = "Threads VN", explanation = "Tên kênh" } [settings.background] -background_video = { optional = true, default = "minecraft", example = "rocket-league", options = ["minecraft", "gta", "rocket-league", "motor-gta", "csgo-surf", "cluster-truck", "minecraft-2","multiversus","fall-guys","steep", ""], explanation = "Sets the background for the video based on game name" } -background_audio = { optional = true, default = "lofi", example = "chill-summer", options = ["lofi","lofi-2","chill-summer",""], explanation = "Sets the background audio for the video" } -background_audio_volume = { optional = true, type = "float", nmin = 0, nmax = 1, default = 0.15, example = 0.05, explanation="Sets the volume of the background audio. If you don't want background audio, set it to 0.", oob_error = "The volume HAS to be between 0 and 1", input_error = "The volume HAS to be a float number between 0 and 1"} -enable_extra_audio = { optional = true, type = "bool", default = false, example = false, explanation="Used if you want to render another video without background audio in a separate folder", input_error = "The value HAS to be true or false"} -background_thumbnail = { optional = true, type = "bool", default = false, example = false, options = [true, false,], explanation = "Generate a thumbnail for the video (put a thumbnail.png file in the assets/backgrounds directory.)" } -background_thumbnail_font_family = { optional = true, default = "arial", example = "arial", explanation = "Font family for the thumbnail text" } -background_thumbnail_font_size = { optional = true, type = "int", default = 96, example = 96, explanation = "Font size in pixels for the thumbnail text" } -background_thumbnail_font_color = { optional = true, default = "255,255,255", example = "255,255,255", explanation = "Font color in RGB format for the thumbnail text" } +background_video = { optional = true, default = "minecraft", example = "minecraft", options = ["minecraft", "gta", "rocket-league", "motor-gta", "csgo-surf", "cluster-truck", "minecraft-2", "multiversus", "fall-guys", "steep", ""], explanation = "Video nền" } +background_audio = { optional = true, default = "lofi", example = "lofi", options = ["lofi", "lofi-2", "chill-summer", ""], explanation = "Nhạc nền" } +background_audio_volume = { optional = true, type = "float", nmin = 0, nmax = 1, default = 0.15, example = 0.05, explanation = "Âm lượng nhạc nền (0-1)" } +enable_extra_audio = { optional = true, type = "bool", default = false, example = false, explanation = "Tạo thêm video chỉ có TTS" } +background_thumbnail = { optional = true, type = "bool", default = false, example = false, options = [true, false], explanation = "Tạo thumbnail tự động" } +background_thumbnail_font_family = { optional = true, default = "arial", example = "arial", explanation = "Font thumbnail" } +background_thumbnail_font_size = { optional = true, type = "int", default = 96, example = 96, explanation = "Cỡ chữ thumbnail" } +background_thumbnail_font_color = { optional = true, default = "255,255,255", example = "255,255,255", explanation = "Màu chữ thumbnail (RGB)" } [settings.tts] -voice_choice = { optional = false, default = "tiktok", options = ["elevenlabs", "streamlabspolly", "tiktok", "googletranslate", "awspolly", "pyttsx", "OpenAI"], example = "tiktok", explanation = "The voice platform used for TTS generation. " } -random_voice = { optional = false, type = "bool", default = true, example = true, options = [true, false,], explanation = "Randomizes the voice used for each comment" } -elevenlabs_voice_name = { optional = false, default = "Bella", example = "Bella", explanation = "The voice used for elevenlabs", options = ["Adam", "Antoni", "Arnold", "Bella", "Domi", "Elli", "Josh", "Rachel", "Sam", ] } -elevenlabs_api_key = { optional = true, example = "21f13f91f54d741e2ae27d2ab1b99d59", explanation = "Elevenlabs API key" } -aws_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "The voice used for AWS Polly" } -streamlabs_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "The voice used for Streamlabs Polly" } -tiktok_voice = { optional = true, default = "en_us_001", example = "en_us_006", explanation = "The voice used for TikTok TTS" } -tiktok_sessionid = { optional = true, example = "c76bcc3a7625abcc27b508c7db457ff1", explanation = "TikTok sessionid needed if you're using the TikTok TTS. Check documentation if you don't know how to obtain it." } -python_voice = { optional = false, default = "1", example = "1", explanation = "The index of the system tts voices (can be downloaded externally, run ptt.py to find value, start from zero)" } -py_voice_num = { optional = false, default = "2", example = "2", explanation = "The number of system voices (2 are pre-installed in Windows)" } -silence_duration = { optional = true, example = "0.1", explanation = "Time in seconds between TTS comments", default = 0.3, type = "float" } -no_emojis = { optional = false, type = "bool", default = false, example = false, options = [true, false,], explanation = "Whether to remove emojis from the comments" } -openai_api_url = { optional = true, default = "https://api.openai.com/v1/", example = "https://api.openai.com/v1/", explanation = "The API endpoint URL for OpenAI TTS generation" } -openai_api_key = { optional = true, example = "sk-abc123def456...", explanation = "Your OpenAI API key for TTS generation" } -openai_voice_name = { optional = false, default = "alloy", example = "alloy", explanation = "The voice used for OpenAI TTS generation", options = ["alloy", "ash", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "af_heart"] } -openai_model = { optional = false, default = "tts-1", example = "tts-1", explanation = "The model variant used for OpenAI TTS generation", options = ["tts-1", "tts-1-hd", "gpt-4o-mini-tts"] } +voice_choice = { optional = false, default = "googletranslate", options = ["elevenlabs", "streamlabspolly", "tiktok", "googletranslate", "awspolly", "pyttsx", "OpenAI"], example = "googletranslate", explanation = "Engine TTS. googletranslate hỗ trợ tiếng Việt tốt nhất" } +random_voice = { optional = false, type = "bool", default = false, example = false, options = [true, false], explanation = "Ngẫu nhiên giọng đọc" } +elevenlabs_voice_name = { optional = false, default = "Bella", example = "Bella", options = ["Adam", "Antoni", "Arnold", "Bella", "Domi", "Elli", "Josh", "Rachel", "Sam"], explanation = "Giọng ElevenLabs" } +elevenlabs_api_key = { optional = true, example = "21f13f91f54d741e2ae27d2ab1b99d59", explanation = "ElevenLabs API key" } +aws_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "Giọng AWS Polly" } +streamlabs_polly_voice = { optional = false, default = "Matthew", example = "Matthew", explanation = "Giọng Streamlabs Polly" } +tiktok_voice = { optional = true, default = "en_us_001", example = "en_us_006", explanation = "Giọng TikTok TTS" } +tiktok_sessionid = { optional = true, example = "c76bcc3a7625abcc27b508c7db457ff1", explanation = "TikTok session ID" } +python_voice = { optional = false, default = "1", example = "1", explanation = "Index giọng hệ thống" } +py_voice_num = { optional = false, default = "2", example = "2", explanation = "Số giọng hệ thống" } +silence_duration = { optional = true, example = "0.1", default = 0.3, type = "float", explanation = "Khoảng lặng giữa TTS (giây)" } +no_emojis = { optional = false, type = "bool", default = false, example = false, options = [true, false], explanation = "Loại bỏ emoji" } +openai_api_url = { optional = true, default = "https://api.openai.com/v1/", example = "https://api.openai.com/v1/", explanation = "OpenAI API URL" } +openai_api_key = { optional = true, example = "sk-abc123...", explanation = "OpenAI API key" } +openai_voice_name = { optional = false, default = "alloy", example = "alloy", options = ["alloy", "ash", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "af_heart"], explanation = "Giọng OpenAI TTS" } +openai_model = { optional = false, default = "tts-1", example = "tts-1", options = ["tts-1", "tts-1-hd", "gpt-4o-mini-tts"], explanation = "Model OpenAI TTS" } + +# ===== AUTO-UPLOAD PLATFORMS ===== + +[uploaders.youtube] +enabled = { optional = true, type = "bool", default = false, options = [true, false], explanation = "Bật upload YouTube" } +client_id = { optional = true, default = "", explanation = "Google OAuth2 Client ID" } +client_secret = { optional = true, default = "", explanation = "Google OAuth2 Client Secret" } +refresh_token = { optional = true, default = "", explanation = "Google OAuth2 Refresh Token" } + +[uploaders.tiktok] +enabled = { optional = true, type = "bool", default = false, options = [true, false], explanation = "Bật upload TikTok" } +client_key = { optional = true, default = "", explanation = "TikTok Client Key" } +client_secret = { optional = true, default = "", explanation = "TikTok Client Secret" } +refresh_token = { optional = true, default = "", explanation = "TikTok Refresh Token" } + +[uploaders.facebook] +enabled = { optional = true, type = "bool", default = false, options = [true, false], explanation = "Bật upload Facebook" } +page_id = { optional = true, default = "", explanation = "Facebook Page ID" } +access_token = { optional = true, default = "", explanation = "Facebook Page Access Token" } + +# ===== SCHEDULER ===== + +[scheduler] +enabled = { optional = true, type = "bool", default = false, options = [true, false], explanation = "Bật lên lịch tự động" } +cron = { optional = true, default = "0 */6 * * *", explanation = "Cron expression. Mặc định: mỗi 6 giờ", example = "0 8,14,20 * * *" } +timezone = { optional = true, default = "Asia/Ho_Chi_Minh", explanation = "Múi giờ", example = "Asia/Ho_Chi_Minh" } +max_videos_per_day = { optional = true, default = 4, type = "int", nmin = 1, nmax = 20, explanation = "Số video tối đa/ngày" } + +# ===== LEGACY REDDIT CONFIG ===== + +[reddit.creds] +client_id = { optional = true, default = "", nmin = 0, explanation = "(Legacy) Reddit client ID" } +client_secret = { optional = true, default = "", nmin = 0, explanation = "(Legacy) Reddit client secret" } +username = { optional = true, default = "", nmin = 0, explanation = "(Legacy) Reddit username" } +password = { optional = true, default = "", nmin = 0, explanation = "(Legacy) Reddit password" } +2fa = { optional = true, type = "bool", options = [true, false], default = false, explanation = "(Legacy) Reddit 2FA" } + +[reddit.thread] +random = { optional = true, options = [true, false], default = false, type = "bool", explanation = "(Legacy)" } +subreddit = { optional = true, default = "AskReddit", nmin = 0, explanation = "(Legacy)" } +post_id = { optional = true, default = "", explanation = "(Legacy)" } +max_comment_length = { default = 500, optional = true, nmin = 10, nmax = 10000, type = "int", explanation = "(Legacy)" } +min_comment_length = { default = 1, optional = true, nmin = 0, nmax = 10000, type = "int", explanation = "(Legacy)" } +post_lang = { default = "vi", optional = true, explanation = "(Legacy)", options = ['','vi','af','ak','am','ar','as','ay','az','be','bg','bho','bm','bn','bs','ca','ceb','ckb','co','cs','cy','da','de','doi','dv','ee','el','en','en-US','eo','es','et','eu','fa','fi','fr','fy','ga','gd','gl','gn','gom','gu','ha','haw','hi','hmn','hr','ht','hu','hy','id','ig','ilo','is','it','iw','ja','jw','ka','kk','km','kn','ko','kri','ku','ky','la','lb','lg','ln','lo','lt','lus','lv','mai','mg','mi','mk','ml','mn','mni-Mtei','mr','ms','mt','my','ne','nl','no','nso','ny','om','or','pa','pl','ps','pt','qu','ro','ru','rw','sa','sd','si','sk','sl','sm','sn','so','sq','sr','st','su','sv','sw','ta','te','tg','th','ti','tk','tl','tr','ts','tt','ug','uk','ur','uz','vi','xh','yi','yo','zh-CN','zh-TW','zu'] } +min_comments = { default = 20, optional = true, nmin = 1, type = "int", explanation = "(Legacy)" } +blocked_words = { optional = true, default = "", type = "str", explanation = "(Legacy)" } diff --git a/utils/videos.py b/utils/videos.py index 7c756fc..cf188bf 100755 --- a/utils/videos.py +++ b/utils/videos.py @@ -1,34 +1,38 @@ import json import time -from praw.models import Submission - from utils import settings from utils.console import print_step def check_done( - redditobj: Submission, -) -> Submission: + redditobj, +): # don't set this to be run anyplace that isn't subreddit.py bc of inspect stack """Checks if the chosen post has already been generated Args: - redditobj (Submission): Reddit object gotten from reddit/subreddit.py + redditobj: Reddit/Threads submission object Returns: - Submission|None: Reddit object in args + The object if not done, None if already done """ with open("./video_creation/data/videos.json", "r", encoding="utf-8") as done_vids_raw: done_videos = json.load(done_vids_raw) for video in done_videos: if video["id"] == str(redditobj): - if settings.config["reddit"]["thread"]["post_id"]: + # Check both threads and reddit config for post_id + post_id = "" + if "threads" in settings.config and "thread" in settings.config["threads"]: + post_id = settings.config["threads"]["thread"].get("post_id", "") + if not post_id and "reddit" in settings.config and "thread" in settings.config["reddit"]: + post_id = settings.config["reddit"]["thread"].get("post_id", "") + if post_id: print_step( - "You already have done this video but since it was declared specifically in the config file the program will continue" + "Video đã được tạo trước đó nhưng được chỉ định cụ thể trong config, tiếp tục..." ) return redditobj - print_step("Getting new post as the current one has already been done") + print_step("Đang lấy bài viết mới vì bài này đã được tạo video") return None return redditobj diff --git a/video_creation/final_video.py b/video_creation/final_video.py index c4f3a0b..621ad4a 100644 --- a/video_creation/final_video.py +++ b/video_creation/final_video.py @@ -75,7 +75,12 @@ def name_normalize(name: str) -> str: name = re.sub(r"(\w+)\s?\/\s?(\w+)", r"\1 or \2", name) name = re.sub(r"\/", r"", name) - lang = settings.config["reddit"]["thread"]["post_lang"] + # Support both Threads and Reddit config + lang = "" + if "threads" in settings.config and "thread" in settings.config["threads"]: + lang = settings.config["threads"]["thread"].get("post_lang", "") + if not lang and "reddit" in settings.config and "thread" in settings.config["reddit"]: + lang = settings.config["reddit"]["thread"].get("post_lang", "") if lang: print_substep("Translating filename...") translated_name = translators.translate_text(name, translator="google", to_language=lang) @@ -359,7 +364,12 @@ def make_final_video( title_thumb = reddit_obj["thread_title"] filename = f"{name_normalize(title)[:251]}" - subreddit = settings.config["reddit"]["thread"]["subreddit"] + # Support both Threads and Reddit config for subreddit/channel name + subreddit = "threads" + if "threads" in settings.config and "thread" in settings.config["threads"]: + subreddit = settings.config["threads"]["thread"].get("channel_name", "threads") + elif "reddit" in settings.config and "thread" in settings.config["reddit"]: + subreddit = settings.config["reddit"]["thread"].get("subreddit", "threads") if not exists(f"./results/{subreddit}"): print_substep("The 'results' folder could not be found so it was automatically created.") diff --git a/video_creation/threads_screenshot.py b/video_creation/threads_screenshot.py new file mode 100644 index 0000000..aaea398 --- /dev/null +++ b/video_creation/threads_screenshot.py @@ -0,0 +1,344 @@ +""" +Threads Screenshot Generator - Tạo hình ảnh giả lập giao diện Threads. + +Sử dụng Pillow để render hình ảnh thay vì chụp screenshot từ trình duyệt, +vì Threads không có giao diện web tĩnh dễ chụp như Reddit. +""" + +import os +import re +import textwrap +from pathlib import Path +from typing import Dict, Final, List, Optional, Tuple + +from PIL import Image, ImageDraw, ImageFont +from rich.progress import track + +from utils import settings +from utils.console import print_step, print_substep + + +# Threads color themes +THEMES = { + "dark": { + "bg_color": (0, 0, 0), + "card_bg": (30, 30, 30), + "text_color": (255, 255, 255), + "secondary_text": (140, 140, 140), + "border_color": (50, 50, 50), + "accent_color": (0, 149, 246), # Threads blue + "reply_line": (60, 60, 60), + }, + "light": { + "bg_color": (255, 255, 255), + "card_bg": (255, 255, 255), + "text_color": (0, 0, 0), + "secondary_text": (130, 130, 130), + "border_color": (219, 219, 219), + "accent_color": (0, 149, 246), + "reply_line": (200, 200, 200), + }, +} + + +def _get_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont: + """Load font hỗ trợ tiếng Việt.""" + font_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fonts") + if bold: + font_path = os.path.join(font_dir, "Roboto-Bold.ttf") + else: + font_path = os.path.join(font_dir, "Roboto-Medium.ttf") + + try: + return ImageFont.truetype(font_path, size) + except (OSError, IOError): + return ImageFont.load_default() + + +def _wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int) -> List[str]: + """Wrap text to fit within max_width pixels.""" + words = text.split() + lines = [] + current_line = "" + + for word in words: + test_line = f"{current_line} {word}".strip() + bbox = font.getbbox(test_line) + if bbox[2] <= max_width: + current_line = test_line + else: + if current_line: + lines.append(current_line) + current_line = word + + if current_line: + lines.append(current_line) + + return lines if lines else [""] + + +def _draw_avatar(draw: ImageDraw.Draw, x: int, y: int, size: int, color: Tuple[int, ...]): + """Vẽ avatar tròn placeholder.""" + draw.ellipse([x, y, x + size, y + size], fill=color) + # Vẽ chữ cái đầu trong avatar + font = _get_font(size // 2, bold=True) + draw.text( + (x + size // 4, y + size // 6), + "T", + fill=(255, 255, 255), + font=font, + ) + + +def create_thread_post_image( + thread_obj: dict, + theme_name: str = "dark", + width: int = 1080, +) -> Image.Image: + """Tạo hình ảnh cho bài viết Threads chính (title/post). + + Args: + thread_obj: Thread object chứa thông tin bài viết. + theme_name: Theme name ("dark" hoặc "light"). + width: Chiều rộng hình ảnh. + + Returns: + PIL Image object. + """ + theme = THEMES.get(theme_name, THEMES["dark"]) + + padding = 40 + content_width = width - (padding * 2) + avatar_size = 60 + + # Fonts + username_font = _get_font(28, bold=True) + body_font = _get_font(32) + meta_font = _get_font(22) + + author = thread_obj.get("thread_author", "@user") + text = thread_obj.get("thread_title", thread_obj.get("thread_post", "")) + + # Tính chiều cao + text_lines = _wrap_text(text, body_font, content_width - avatar_size - 30) + line_height = 42 + text_height = len(text_lines) * line_height + + total_height = padding + avatar_size + 20 + text_height + 60 + padding + + # Tạo image + img = Image.new("RGBA", (width, total_height), theme["bg_color"]) + draw = ImageDraw.Draw(img) + + y_cursor = padding + + # Avatar + _draw_avatar(draw, padding, y_cursor, avatar_size, theme["accent_color"]) + + # Username + draw.text( + (padding + avatar_size + 15, y_cursor + 5), + author, + fill=theme["text_color"], + font=username_font, + ) + + # Timestamp + draw.text( + (padding + avatar_size + 15, y_cursor + 35), + "🧵 Threads", + fill=theme["secondary_text"], + font=meta_font, + ) + + y_cursor += avatar_size + 20 + + # Thread line (vertical line from avatar to content) + line_x = padding + avatar_size // 2 + draw.line( + [(line_x, padding + avatar_size + 5), (line_x, y_cursor - 5)], + fill=theme["reply_line"], + width=3, + ) + + # Body text + for line in text_lines: + draw.text( + (padding + 10, y_cursor), + line, + fill=theme["text_color"], + font=body_font, + ) + y_cursor += line_height + + # Interaction bar + y_cursor += 20 + icons = "❤️ 💬 🔄 ✈️" + draw.text( + (padding + 10, y_cursor), + icons, + fill=theme["secondary_text"], + font=meta_font, + ) + + return img + + +def create_comment_image( + comment: dict, + index: int, + theme_name: str = "dark", + width: int = 1080, +) -> Image.Image: + """Tạo hình ảnh cho một reply/comment trên Threads. + + Args: + comment: Comment dict. + index: Số thứ tự comment. + theme_name: Theme name. + width: Chiều rộng hình ảnh. + + Returns: + PIL Image object. + """ + theme = THEMES.get(theme_name, THEMES["dark"]) + + padding = 40 + content_width = width - (padding * 2) + avatar_size = 50 + + # Fonts + username_font = _get_font(24, bold=True) + body_font = _get_font(30) + meta_font = _get_font(20) + + author = comment.get("comment_author", f"@user{index}") + text = comment.get("comment_body", "") + + # Tính chiều cao + text_lines = _wrap_text(text, body_font, content_width - avatar_size - 30) + line_height = 40 + text_height = len(text_lines) * line_height + + total_height = padding + avatar_size + 15 + text_height + 40 + padding + + # Tạo image + img = Image.new("RGBA", (width, total_height), theme["bg_color"]) + draw = ImageDraw.Draw(img) + + y_cursor = padding + + # Reply line at top + draw.line( + [(padding, 0), (padding, y_cursor)], + fill=theme["reply_line"], + width=2, + ) + + # Avatar (smaller for comments) + colors = [ + (88, 101, 242), # Blue + (237, 66, 69), # Red + (87, 242, 135), # Green + (254, 231, 92), # Yellow + (235, 69, 158), # Pink + ] + avatar_color = colors[index % len(colors)] + _draw_avatar(draw, padding, y_cursor, avatar_size, avatar_color) + + # Username + draw.text( + (padding + avatar_size + 12, y_cursor + 5), + author, + fill=theme["text_color"], + font=username_font, + ) + + # Time indicator + draw.text( + (padding + avatar_size + 12, y_cursor + 30), + "Trả lời", + fill=theme["secondary_text"], + font=meta_font, + ) + + y_cursor += avatar_size + 15 + + # Body text + for line in text_lines: + draw.text( + (padding + 10, y_cursor), + line, + fill=theme["text_color"], + font=body_font, + ) + y_cursor += line_height + + # Bottom separator + y_cursor += 10 + draw.line( + [(padding, y_cursor), (width - padding, y_cursor)], + fill=theme["border_color"], + width=1, + ) + + return img + + +def get_screenshots_of_threads_posts(thread_object: dict, screenshot_num: int): + """Tạo screenshots cho bài viết Threads. + + Thay thế get_screenshots_of_reddit_posts() cho Threads. + + Args: + thread_object: Thread object từ threads_client.py. + screenshot_num: Số lượng screenshots cần tạo. + """ + W: Final[int] = int(settings.config["settings"]["resolution_w"]) + H: Final[int] = int(settings.config["settings"]["resolution_h"]) + theme: str = settings.config["settings"].get("theme", "dark") + storymode: bool = settings.config["settings"].get("storymode", False) + + print_step("Đang tạo hình ảnh cho bài viết Threads...") + + thread_id = re.sub(r"[^\w\s-]", "", thread_object["thread_id"]) + Path(f"assets/temp/{thread_id}/png").mkdir(parents=True, exist_ok=True) + + # Tạo hình ảnh cho bài viết chính (title) + title_img = create_thread_post_image( + thread_object, + theme_name=theme if theme in THEMES else "dark", + width=W, + ) + title_img.save(f"assets/temp/{thread_id}/png/title.png") + print_substep("Đã tạo hình ảnh tiêu đề", style="bold green") + + if storymode: + # Story mode - chỉ cần 1 hình cho toàn bộ nội dung + story_img = create_thread_post_image( + { + "thread_author": thread_object.get("thread_author", "@user"), + "thread_title": thread_object.get("thread_post", ""), + }, + theme_name=theme if theme in THEMES else "dark", + width=W, + ) + story_img.save(f"assets/temp/{thread_id}/png/story_content.png") + else: + # Comment mode - tạo hình cho từng reply + comments = thread_object.get("comments", [])[:screenshot_num] + for idx, comment in enumerate( + track(comments, "Đang tạo hình ảnh replies...") + ): + if idx >= screenshot_num: + break + + comment_img = create_comment_image( + comment, + index=idx, + theme_name=theme if theme in THEMES else "dark", + width=W, + ) + comment_img.save(f"assets/temp/{thread_id}/png/comment_{idx}.png") + + print_substep("Đã tạo tất cả hình ảnh thành công! ✅", style="bold green") From 2c6fa251e67bcd249a3a9bb255285e63dfc1e594 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:01:03 +0000 Subject: [PATCH 3/6] Address code review feedback: exponential backoff, avatar colors constant, dynamic upload timeout Agent-Logs-Url: https://github.com/thaitien280401-stack/RedditVideoMakerBot/sessions/b2183a86-2887-4db0-82aa-07d9da5aa1be Co-authored-by: thaitien280401-stack <271128961+thaitien280401-stack@users.noreply.github.com> --- uploaders/base_uploader.py | 5 +++++ uploaders/youtube_uploader.py | 4 +++- video_creation/threads_screenshot.py | 18 ++++++++++-------- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/uploaders/base_uploader.py b/uploaders/base_uploader.py index 0405096..e67b0f1 100644 --- a/uploaders/base_uploader.py +++ b/uploaders/base_uploader.py @@ -3,6 +3,7 @@ Base Uploader - Lớp cơ sở cho tất cả uploaders. """ import os +import time from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Optional, List @@ -112,6 +113,10 @@ class BaseUploader(ABC): f"[{self.platform_name}] Lỗi upload (lần {attempt}): {e}", style="bold red", ) + if attempt < max_retries: + backoff = min(2 ** attempt, 60) # Exponential backoff, max 60s + print_substep(f"Chờ {backoff}s trước khi thử lại...", style="bold yellow") + time.sleep(backoff) print_substep(f"Upload {self.platform_name} thất bại sau {max_retries} lần thử!", style="bold red") return None diff --git a/uploaders/youtube_uploader.py b/uploaders/youtube_uploader.py index 332e837..10b4a82 100644 --- a/uploaders/youtube_uploader.py +++ b/uploaders/youtube_uploader.py @@ -150,6 +150,8 @@ class YouTubeUploader(BaseUploader): # Step 2: Upload video file file_size = os.path.getsize(metadata.file_path) + # Dynamic timeout: minimum 120s, add 60s per 100MB + upload_timeout = max(120, 60 * (file_size // (100 * 1024 * 1024) + 1)) with open(metadata.file_path, "rb") as video_file: upload_response = requests.put( upload_url, @@ -158,7 +160,7 @@ class YouTubeUploader(BaseUploader): "Content-Length": str(file_size), }, data=video_file, - timeout=600, # 10 minutes timeout for large files + timeout=upload_timeout, ) upload_response.raise_for_status() diff --git a/video_creation/threads_screenshot.py b/video_creation/threads_screenshot.py index aaea398..af42728 100644 --- a/video_creation/threads_screenshot.py +++ b/video_creation/threads_screenshot.py @@ -40,6 +40,15 @@ THEMES = { }, } +# Avatar color palette for comments +AVATAR_COLORS = [ + (88, 101, 242), # Blue + (237, 66, 69), # Red + (87, 242, 135), # Green + (254, 231, 92), # Yellow + (235, 69, 158), # Pink +] + def _get_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont: """Load font hỗ trợ tiếng Việt.""" @@ -236,14 +245,7 @@ def create_comment_image( ) # Avatar (smaller for comments) - colors = [ - (88, 101, 242), # Blue - (237, 66, 69), # Red - (87, 242, 135), # Green - (254, 231, 92), # Yellow - (235, 69, 158), # Pink - ] - avatar_color = colors[index % len(colors)] + avatar_color = AVATAR_COLORS[index % len(AVATAR_COLORS)] _draw_avatar(draw, padding, y_cursor, avatar_size, avatar_color) # Username From cb0fddf072e8c04cc0af8e430c089d49cb34df20 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:15:08 +0000 Subject: [PATCH 4/6] Update scheduler to 1 video per 3h with title dedup to prevent duplicates - Changed default cron from every 6h to every 3h (8 videos/day) - Added utils/title_history.py: tracks used titles in JSON - threads_client.py: skips threads with already-used titles - scheduler/pipeline.py: saves title after successful video creation - main.py: saves title in manual mode too - Updated config template with new scheduler defaults Agent-Logs-Url: https://github.com/thaitien280401-stack/RedditVideoMakerBot/sessions/17c7c41c-cf86-4279-88b8-01cba23ee763 Co-authored-by: thaitien280401-stack <271128961+thaitien280401-stack@users.noreply.github.com> --- .gitignore | 1 + main.py | 7 +++ scheduler/pipeline.py | 12 +++- threads/threads_client.py | 11 +++- utils/.config.template.toml | 4 +- utils/title_history.py | 108 ++++++++++++++++++++++++++++++++++++ 6 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 utils/title_history.py diff --git a/.gitignore b/.gitignore index cc6bd18..ab26840 100644 --- a/.gitignore +++ b/.gitignore @@ -242,6 +242,7 @@ reddit-bot-351418-5560ebc49cac.json /.idea *.pyc video_creation/data/videos.json +video_creation/data/title_history.json video_creation/data/envvars.txt config.toml diff --git a/main.py b/main.py index 46a7f90..320dd8d 100755 --- a/main.py +++ b/main.py @@ -95,6 +95,13 @@ def main_threads(POST_ID=None) -> None: chop_background(bg_config, length, thread_object) make_final_video(number_of_comments, length, thread_object, bg_config) + # Lưu title vào lịch sử để tránh tạo trùng lặp + from utils.title_history import save_title + title = thread_object.get("thread_title", "") + tid = thread_object.get("thread_id", "") + if title: + save_title(title=title, thread_id=tid, source="threads") + def main_threads_with_upload(POST_ID=None) -> None: """Pipeline đầy đủ: Threads → Video → Upload lên các platform.""" diff --git a/scheduler/pipeline.py b/scheduler/pipeline.py index 8c8d1b0..f98e477 100644 --- a/scheduler/pipeline.py +++ b/scheduler/pipeline.py @@ -17,6 +17,7 @@ from utils import settings from utils.cleanup import cleanup from utils.console import print_markdown, print_step, print_substep from utils.id import extract_id +from utils.title_history import save_title def run_pipeline(post_id: Optional[str] = None) -> Optional[str]: @@ -128,6 +129,13 @@ def run_pipeline(post_id: Optional[str] = None) -> Optional[str]: print_substep(f" ❌ {platform}: Thất bại", style="bold red") print_step("✅ Pipeline hoàn tất!") + + # Lưu title vào lịch sử để tránh tạo trùng lặp + title = thread_object.get("thread_title", "") + tid = thread_object.get("thread_id", "") + if title: + save_title(title=title, thread_id=tid, source="threads") + return video_path except Exception as e: @@ -158,8 +166,8 @@ def run_scheduled(): return timezone = scheduler_config.get("timezone", "Asia/Ho_Chi_Minh") - cron_expression = scheduler_config.get("cron", "0 */6 * * *") # Mặc định mỗi 6 giờ - max_videos_per_day = scheduler_config.get("max_videos_per_day", 4) + cron_expression = scheduler_config.get("cron", "0 */3 * * *") # Mặc định mỗi 3 giờ (8 lần/ngày: 00, 03, 06, 09, 12, 15, 18, 21h) + max_videos_per_day = scheduler_config.get("max_videos_per_day", 8) # Parse cron expression cron_parts = cron_expression.split() diff --git a/threads/threads_client.py b/threads/threads_client.py index 74ea2c8..dc42cf8 100644 --- a/threads/threads_client.py +++ b/threads/threads_client.py @@ -12,6 +12,7 @@ import requests from utils import settings from utils.console import print_step, print_substep +from utils.title_history import is_title_used from utils.videos import check_done from utils.voice import sanitize_text @@ -158,7 +159,7 @@ def get_threads_posts(POST_ID: str = None) -> dict: keyword_list = [k.strip() for k in keywords.split(",") if k.strip()] threads_list = client.search_threads_by_keyword(threads_list, keyword_list) - # Chọn thread phù hợp (chưa tạo video, đủ replies) + # Chọn thread phù hợp (chưa tạo video, đủ replies, title chưa dùng) thread = None for t in threads_list: thread_id = t.get("id", "") @@ -166,6 +167,14 @@ def get_threads_posts(POST_ID: str = None) -> dict: text = t.get("text", "") if not text or _contains_blocked_words(text): continue + # Kiểm tra title đã được sử dụng chưa (tránh trùng lặp) + title_candidate = text[:200] if len(text) > 200 else text + if is_title_used(title_candidate): + print_substep( + f"Bỏ qua thread đã tạo video: {text[:50]}...", + style="bold yellow", + ) + continue # Kiểm tra số lượng replies try: replies = client.get_thread_replies(thread_id, limit=min_comments + 5) diff --git a/utils/.config.template.toml b/utils/.config.template.toml index 0ac45e5..afac20c 100644 --- a/utils/.config.template.toml +++ b/utils/.config.template.toml @@ -83,9 +83,9 @@ access_token = { optional = true, default = "", explanation = "Facebook Page Acc [scheduler] enabled = { optional = true, type = "bool", default = false, options = [true, false], explanation = "Bật lên lịch tự động" } -cron = { optional = true, default = "0 */6 * * *", explanation = "Cron expression. Mặc định: mỗi 6 giờ", example = "0 8,14,20 * * *" } +cron = { optional = true, default = "0 */3 * * *", explanation = "Cron expression. Mặc định: mỗi 3 giờ (8 video/ngày)", example = "0 */3 * * *" } timezone = { optional = true, default = "Asia/Ho_Chi_Minh", explanation = "Múi giờ", example = "Asia/Ho_Chi_Minh" } -max_videos_per_day = { optional = true, default = 4, type = "int", nmin = 1, nmax = 20, explanation = "Số video tối đa/ngày" } +max_videos_per_day = { optional = true, default = 8, type = "int", nmin = 1, nmax = 50, explanation = "Số video tối đa/ngày. Mặc định: 8 (mỗi 3 giờ × 1 video)" } # ===== LEGACY REDDIT CONFIG ===== diff --git a/utils/title_history.py b/utils/title_history.py new file mode 100644 index 0000000..b2d1012 --- /dev/null +++ b/utils/title_history.py @@ -0,0 +1,108 @@ +""" +Title History - Lưu và kiểm tra các title đã được sử dụng để tránh trùng lặp. + +Lưu trữ danh sách title đã tạo video vào file JSON. +Khi chọn thread mới, kiểm tra xem title đã được sử dụng chưa. +""" + +import json +import os +import time +from typing import Optional + +from utils.console import print_substep + +TITLE_HISTORY_PATH = "./video_creation/data/title_history.json" + + +def _ensure_file_exists() -> None: + """Tạo file title_history.json nếu chưa tồn tại.""" + os.makedirs(os.path.dirname(TITLE_HISTORY_PATH), exist_ok=True) + if not os.path.exists(TITLE_HISTORY_PATH): + with open(TITLE_HISTORY_PATH, "w", encoding="utf-8") as f: + json.dump([], f) + + +def load_title_history() -> list: + """Đọc danh sách title đã sử dụng. + + Returns: + Danh sách các dict chứa thông tin title đã dùng. + """ + _ensure_file_exists() + try: + with open(TITLE_HISTORY_PATH, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, ValueError): + return [] + + +def is_title_used(title: str) -> bool: + """Kiểm tra xem title đã được sử dụng chưa. + + So sánh bằng cách chuẩn hóa (lowercase, strip) để tránh trùng lặp + do khác biệt chữ hoa/thường hoặc khoảng trắng. + + Args: + title: Title cần kiểm tra. + + Returns: + True nếu title đã được sử dụng, False nếu chưa. + """ + if not title or not title.strip(): + return False + + history = load_title_history() + normalized_title = title.strip().lower() + + for entry in history: + saved_title = entry.get("title", "").strip().lower() + if saved_title == normalized_title: + return True + + return False + + +def save_title(title: str, thread_id: str = "", source: str = "threads") -> None: + """Lưu title đã sử dụng vào lịch sử. + + Args: + title: Title của video đã tạo. + thread_id: ID của thread (để tham chiếu). + source: Nguồn nội dung (threads/reddit). + """ + if not title or not title.strip(): + return + + _ensure_file_exists() + + history = load_title_history() + + # Kiểm tra trùng trước khi lưu + normalized_title = title.strip().lower() + for entry in history: + if entry.get("title", "").strip().lower() == normalized_title: + print_substep(f"Title đã tồn tại trong lịch sử, bỏ qua: {title[:50]}...", style="dim") + return + + entry = { + "title": title.strip(), + "thread_id": thread_id, + "source": source, + "created_at": int(time.time()), + } + history.append(entry) + + with open(TITLE_HISTORY_PATH, "w", encoding="utf-8") as f: + json.dump(history, f, ensure_ascii=False, indent=4) + + print_substep(f"Đã lưu title vào lịch sử: {title[:50]}...", style="bold green") + + +def get_title_count() -> int: + """Đếm số title đã sử dụng. + + Returns: + Số lượng title trong lịch sử. + """ + return len(load_title_history()) From bbcd520fbb7006403e64ac4a6fbea7a9ca50237f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:26:53 +0000 Subject: [PATCH 5/6] Security audit: remove eval() calls, fix hardcoded secret, add VPS guide - Replace all unsafe eval() calls with safe _SAFE_TYPE_MAP in settings.py, console.py, gui_utils.py - Replace hardcoded Flask secret key with os.urandom(24) in GUI.py - Replace os.system() with subprocess.run() in TTS/engine_wrapper.py - Fix install.sh Homebrew URL (was uninstall.sh instead of install.sh) - Create HUONG_DAN_VPS.md - comprehensive VPS deployment guide in Vietnamese Agent-Logs-Url: https://github.com/thaitien280401-stack/RedditVideoMakerBot/sessions/75dc3e66-3e99-4406-af07-9de9be3200b6 Co-authored-by: thaitien280401-stack <271128961+thaitien280401-stack@users.noreply.github.com> --- GUI.py | 5 +- HUONG_DAN_VPS.md | 358 ++++++++++++++++++++++++++++++++++++++++++ TTS/engine_wrapper.py | 17 +- install.sh | 2 +- utils/console.py | 2 +- utils/gui_utils.py | 19 ++- utils/settings.py | 38 ++++- 7 files changed, 428 insertions(+), 13 deletions(-) create mode 100644 HUONG_DAN_VPS.md diff --git a/GUI.py b/GUI.py index 4588083..a1d4d0c 100644 --- a/GUI.py +++ b/GUI.py @@ -1,3 +1,4 @@ +import os import webbrowser from pathlib import Path @@ -22,8 +23,8 @@ PORT = 4000 # Configure application app = Flask(__name__, template_folder="GUI") -# Configure secret key only to use 'flash' -app.secret_key = b'_5#y2L"F4Q8z\n\xec]/' +# Configure secret key from environment variable or generate a random one +app.secret_key = os.environ.get("FLASK_SECRET_KEY", os.urandom(24)) # Ensure responses aren't cached diff --git a/HUONG_DAN_VPS.md b/HUONG_DAN_VPS.md new file mode 100644 index 0000000..0c8d90c --- /dev/null +++ b/HUONG_DAN_VPS.md @@ -0,0 +1,358 @@ +# 🇻🇳 HƯỚNG DẪN CÀI ĐẶT VÀ CHẠY TRÊN VPS + +## Mục lục + +1. [Yêu cầu hệ thống](#1-yêu-cầu-hệ-thống) +2. [Cài đặt trên VPS](#2-cài-đặt-trên-vps) +3. [Cấu hình bắt buộc](#3-cấu-hình-bắt-buộc-configtoml) +4. [Các chế độ chạy](#4-các-chế-độ-chạy) +5. [Chạy nền trên VPS](#5-chạy-nền-trên-vps-với-systemd) +6. [Chạy với Docker](#6-chạy-với-docker-tùy-chọn) +7. [Kiểm tra và khắc phục lỗi](#7-kiểm-tra-và-khắc-phục-lỗi) +8. [Bảng tóm tắt cấu hình](#8-bảng-tóm-tắt-cấu-hình-cần-cập-nhật) + +--- + +## 1. Yêu cầu hệ thống + +| Thành phần | Yêu cầu tối thiểu | +|---|---| +| **OS** | Ubuntu 20.04+ / Debian 11+ | +| **RAM** | 2 GB (khuyến nghị 4 GB) | +| **Disk** | 10 GB trống | +| **Python** | 3.10, 3.11 hoặc 3.12 | +| **FFmpeg** | Bắt buộc (cài tự động nếu thiếu) | + +--- + +## 2. Cài đặt trên VPS + +### Bước 1: Cập nhật hệ thống và cài đặt phụ thuộc + +```bash +sudo apt update && sudo apt upgrade -y +sudo apt install -y python3 python3-pip python3-venv ffmpeg git +``` + +### Bước 2: Clone dự án + +```bash +cd /opt +git clone https://github.com/thaitien280401-stack/RedditVideoMakerBot.git +cd RedditVideoMakerBot +``` + +### Bước 3: Tạo virtual environment + +```bash +python3 -m venv venv +source venv/bin/activate +``` + +### Bước 4: Cài đặt thư viện + +```bash +pip install -r requirements.txt +``` + +### Bước 5: Cài đặt Playwright browser (cần cho chế độ screenshot) + +```bash +python -m playwright install +python -m playwright install-deps +``` + +--- + +## 3. Cấu hình bắt buộc (`config.toml`) + +Khi chạy lần đầu, chương trình sẽ tự tạo file `config.toml` và hỏi bạn nhập thông tin. +Bạn cũng có thể tạo trước file `config.toml` trong thư mục gốc dự án: + +```toml +# ===== CẤU HÌNH BẮT BUỘC ===== + +[threads.creds] +access_token = "YOUR_THREADS_ACCESS_TOKEN" # Lấy từ Meta Developer Portal +user_id = "YOUR_THREADS_USER_ID" # Threads User ID + +[threads.thread] +target_user_id = "" # Để trống = dùng user của bạn +post_id = "" # Để trống = tự động chọn thread mới nhất +keywords = "viral, trending, hài hước" # Từ khóa lọc (tùy chọn) +max_comment_length = 500 +min_comment_length = 1 +post_lang = "vi" +min_comments = 5 +blocked_words = "spam, quảng cáo" +channel_name = "Threads Vietnam" + +[settings] +allow_nsfw = false +theme = "dark" +times_to_run = 1 +opacity = 0.9 +resolution_w = 1080 +resolution_h = 1920 + +[settings.background] +background_video = "minecraft" +background_audio = "lofi" +background_audio_volume = 0.15 + +[settings.tts] +voice_choice = "googletranslate" # Tốt nhất cho tiếng Việt +silence_duration = 0.3 +no_emojis = false + +# ===== SCHEDULER (lên lịch tự động) ===== + +[scheduler] +enabled = true # BẬT lên lịch tự động +cron = "0 */3 * * *" # Mỗi 3 giờ tạo 1 video +timezone = "Asia/Ho_Chi_Minh" # Múi giờ Việt Nam +max_videos_per_day = 8 # Tối đa 8 video/ngày + +# ===== UPLOAD TỰ ĐỘNG (tùy chọn) ===== + +[uploaders.youtube] +enabled = false +client_id = "" +client_secret = "" +refresh_token = "" + +[uploaders.tiktok] +enabled = false +client_key = "" +client_secret = "" +refresh_token = "" + +[uploaders.facebook] +enabled = false +page_id = "" +access_token = "" +``` + +### Cách lấy Threads API credentials + +1. Truy cập [Meta Developer Portal](https://developers.facebook.com/) +2. Tạo App mới → chọn "Business" type +3. Thêm product "Threads API" +4. Vào Settings → Basic → lấy **App ID** +5. Tạo Access Token cho Threads API +6. Lấy **User ID** từ Threads API endpoint: `GET /me?fields=id,username` + +--- + +## 4. Các chế độ chạy + +### 4.1. Manual (thủ công) — Mặc định +Tạo video 1 lần, không upload: +```bash +python main.py +``` + +### 4.2. Auto (tạo + upload) +Tạo video và tự động upload lên các platform đã cấu hình: +```bash +python main.py --mode auto +``` + +### 4.3. ⭐ Scheduled (lên lịch tự động) — KHUYẾN NGHỊ CHO VPS +Chạy liên tục trên VPS, tự động tạo video theo lịch trình: +```bash +python main.py --mode scheduled +``` + +**Mặc định:** +- Cron: `0 */3 * * *` → Tạo 1 video **mỗi 3 giờ** +- Lịch chạy: 00:00, 03:00, 06:00, 09:00, 12:00, 15:00, 18:00, 21:00 (giờ VN) +- **= 8 video/ngày** +- Timezone: `Asia/Ho_Chi_Minh` +- Tự động bỏ qua các chủ đề đã tạo video (title deduplication) +- Giới hạn tối đa `max_videos_per_day` video mỗi ngày + +### Tùy chỉnh lịch chạy + +Thay đổi `cron` trong `config.toml`: + +| Cron Expression | Mô tả | Video/ngày | +|---|---|---| +| `0 */3 * * *` | Mỗi 3 giờ (mặc định) | 8 | +| `0 */4 * * *` | Mỗi 4 giờ | 6 | +| `0 */6 * * *` | Mỗi 6 giờ | 4 | +| `0 8,14,20 * * *` | Lúc 8h, 14h, 20h | 3 | +| `0 */2 * * *` | Mỗi 2 giờ | 12 | + +--- + +## 5. Chạy nền trên VPS với systemd + +### Bước 1: Tạo systemd service + +```bash +sudo nano /etc/systemd/system/threads-video-bot.service +``` + +Dán nội dung sau: + +```ini +[Unit] +Description=Threads Video Maker Bot +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/RedditVideoMakerBot +ExecStart=/opt/RedditVideoMakerBot/venv/bin/python main.py --mode scheduled +Restart=always +RestartSec=30 +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target +``` + +### Bước 2: Kích hoạt và khởi động + +```bash +sudo systemctl daemon-reload +sudo systemctl enable threads-video-bot +sudo systemctl start threads-video-bot +``` + +### Bước 3: Kiểm tra trạng thái + +```bash +# Xem trạng thái +sudo systemctl status threads-video-bot + +# Xem log realtime +sudo journalctl -u threads-video-bot -f + +# Xem log gần nhất +sudo journalctl -u threads-video-bot --since "1 hour ago" + +# Restart +sudo systemctl restart threads-video-bot + +# Dừng +sudo systemctl stop threads-video-bot +``` + +--- + +## 6. Chạy với Docker (tùy chọn) + +### Build image + +```bash +cd /opt/RedditVideoMakerBot +docker build -t threads-video-bot . +``` + +### Chạy container + +```bash +docker run -d \ + --name threads-bot \ + --restart unless-stopped \ + -v $(pwd)/config.toml:/app/config.toml \ + -v $(pwd)/results:/app/results \ + -v $(pwd)/video_creation/data:/app/video_creation/data \ + threads-video-bot python3 main.py --mode scheduled +``` + +### Xem log + +```bash +docker logs -f threads-bot +``` + +--- + +## 7. Kiểm tra và khắc phục lỗi + +### Kiểm tra trạng thái + +```bash +# Service đang chạy? +sudo systemctl is-active threads-video-bot + +# Xem lỗi gần nhất +sudo journalctl -u threads-video-bot --since "30 min ago" --no-pager + +# Đếm video đã tạo +ls -la results/*/ +``` + +### Lỗi thường gặp + +| Lỗi | Nguyên nhân | Cách khắc phục | +|---|---|---| +| `ModuleNotFoundError` | Thiếu thư viện | `source venv/bin/activate && pip install -r requirements.txt` | +| `FileNotFoundError: ffmpeg` | Chưa cài FFmpeg | `sudo apt install ffmpeg` | +| `Threads API error 401` | Token hết hạn | Tạo access token mới từ Meta Developer Portal | +| `No suitable thread found` | Hết thread mới | Đợi có thread mới hoặc thay `target_user_id` | +| `playwright._impl._errors` | Thiếu browser | `python -m playwright install && python -m playwright install-deps` | +| `Đã đạt giới hạn X video/ngày` | Đã tạo đủ video | Bình thường, sẽ reset vào ngày hôm sau | + +### Lịch sử title (tránh trùng lặp) + +- File lưu: `video_creation/data/title_history.json` +- Xem title đã tạo: `cat video_creation/data/title_history.json | python -m json.tool` +- Reset (cho phép tạo lại tất cả): `echo "[]" > video_creation/data/title_history.json` + +--- + +## 8. Bảng tóm tắt cấu hình cần cập nhật + +### ⚠️ BẮT BUỘC phải thay đổi + +| Mục | Key trong config.toml | Mô tả | Cách lấy | +|---|---|---|---| +| **Threads Token** | `threads.creds.access_token` | Access token API | [Meta Developer Portal](https://developers.facebook.com/) | +| **Threads User ID** | `threads.creds.user_id` | User ID Threads | API endpoint `/me?fields=id` | + +### 📋 Nên tùy chỉnh + +| Mục | Key | Mặc định | Gợi ý | +|---|---|---|---| +| Tên kênh | `threads.thread.channel_name` | "Threads Vietnam" | Tên kênh của bạn | +| Từ khóa | `threads.thread.keywords` | "" | "viral, trending, hài hước" | +| Từ bị chặn | `threads.thread.blocked_words` | "" | "spam, quảng cáo, 18+" | +| Lịch chạy | `scheduler.cron` | `0 */3 * * *` | Xem bảng ở mục 4 | +| Max video/ngày | `scheduler.max_videos_per_day` | 8 | Tùy chỉnh | + +### 🔧 Tùy chọn: Upload tự động + +| Platform | Keys cần cấu hình | +|---|---| +| **YouTube** | `uploaders.youtube.client_id`, `client_secret`, `refresh_token` | +| **TikTok** | `uploaders.tiktok.client_key`, `client_secret`, `refresh_token` | +| **Facebook** | `uploaders.facebook.page_id`, `access_token` | + +--- + +## Tóm tắt nhanh + +```bash +# 1. Cài đặt +cd /opt/RedditVideoMakerBot +python3 -m venv venv && source venv/bin/activate +pip install -r requirements.txt +python -m playwright install && python -m playwright install-deps + +# 2. Cấu hình +nano config.toml # Nhập thông tin Threads API + +# 3. Test thử 1 video +python main.py + +# 4. Chạy tự động trên VPS (mỗi 3h = 8 video/ngày) +python main.py --mode scheduled + +# 5. Hoặc chạy nền với systemd (khuyến nghị) +sudo systemctl enable --now threads-video-bot +``` diff --git a/TTS/engine_wrapper.py b/TTS/engine_wrapper.py index fd0f9da..9020da9 100644 --- a/TTS/engine_wrapper.py +++ b/TTS/engine_wrapper.py @@ -1,5 +1,6 @@ import os import re +import subprocess from pathlib import Path from typing import Tuple @@ -127,12 +128,16 @@ class TTSEngine: split_files.append(str(f"{self.path}/{idx}-{idy}.part.mp3")) f.write("file " + f"'silence.mp3'" + "\n") - os.system( - "ffmpeg -f concat -y -hide_banner -loglevel panic -safe 0 " - + "-i " - + f"{self.path}/list.txt " - + "-c copy " - + f"{self.path}/{idx}.mp3" + subprocess.run( + [ + "ffmpeg", "-f", "concat", "-y", + "-hide_banner", "-loglevel", "panic", + "-safe", "0", + "-i", f"{self.path}/list.txt", + "-c", "copy", + f"{self.path}/{idx}.mp3", + ], + check=False, ) try: for i in range(0, len(split_files)): diff --git a/install.sh b/install.sh index 38c1708..08612c1 100644 --- a/install.sh +++ b/install.sh @@ -50,7 +50,7 @@ function install_macos(){ if [ ! command -v brew &> /dev/null ]; then echo "Installing Homebrew" # if it's is not installed, then install it in a NONINTERACTIVE way - NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/uninstall.sh)" + NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" # Check for what arcitecture, so you can place path. if [[ "uname -m" == "x86_64" ]]; then echo "export PATH=/usr/local/bin:$PATH" >> ~/.bash_profile && source ~/.bash_profile diff --git a/utils/console.py b/utils/console.py index a9abf4b..f47a9b9 100644 --- a/utils/console.py +++ b/utils/console.py @@ -102,7 +102,7 @@ def handle_input( user_input = input("").strip() if check_type is not False: try: - isinstance(eval(user_input), check_type) # fixme: remove eval + check_type(user_input) return check_type(user_input) except: console.print( diff --git a/utils/gui_utils.py b/utils/gui_utils.py index 31c135f..1337c06 100644 --- a/utils/gui_utils.py +++ b/utils/gui_utils.py @@ -6,6 +6,14 @@ import toml import tomlkit from flask import flash +# Safe type mapping to replace eval() calls +_SAFE_TYPE_MAP = { + "int": int, + "float": float, + "str": str, + "bool": bool, +} + # Get validation checks from template def get_checks(): @@ -46,7 +54,16 @@ def check(value, checks): if not incorrect and "type" in checks: try: - value = eval(checks["type"])(value) # fixme remove eval + target_type = _SAFE_TYPE_MAP.get(checks["type"]) + if target_type is None: + incorrect = True + elif target_type is bool: + if isinstance(value, str): + value = value.lower() in ("true", "1", "yes") + else: + value = bool(value) + else: + value = target_type(value) except Exception: incorrect = True diff --git a/utils/settings.py b/utils/settings.py index 6b8242b..085a963 100755 --- a/utils/settings.py +++ b/utils/settings.py @@ -10,6 +10,40 @@ from utils.console import handle_input console = Console() config = dict # autocomplete +# Safe type mapping to replace eval() calls +_SAFE_TYPE_MAP = { + "int": int, + "float": float, + "str": str, + "bool": bool, + "False": False, +} + + +def _safe_type_cast(type_str, value): + """Safely cast value to type without eval(). + + Args: + type_str: String name of the type (e.g. 'int', 'float', 'str', 'bool'). + value: The value to cast. + + Returns: + The cast value. + + Raises: + ValueError: If the type string is not recognized. + """ + if type_str in _SAFE_TYPE_MAP: + target_type = _SAFE_TYPE_MAP[type_str] + if target_type is False: + return False + if target_type is bool: + if isinstance(value, str): + return value.lower() in ("true", "1", "yes") + return bool(value) + return target_type(value) + raise ValueError(f"Unknown type: {type_str}") + def crawl(obj: dict, func=lambda x, y: print(x, y, end="\n"), path=None): if path is None: # path Default argument value is mutable @@ -30,7 +64,7 @@ def check(value, checks, name): incorrect = True if not incorrect and "type" in checks: try: - value = eval(checks["type"])(value) # fixme remove eval + value = _safe_type_cast(checks["type"], value) except: incorrect = True @@ -78,7 +112,7 @@ def check(value, checks, name): + str(name) + "[#F7768E bold]=", extra_info=get_check_value("explanation", ""), - check_type=eval(get_check_value("type", "False")), # fixme remove eval + check_type=_SAFE_TYPE_MAP.get(get_check_value("type", "False"), False), default=get_check_value("default", NotImplemented), match=get_check_value("regex", ""), err_message=get_check_value("input_error", "Incorrect input"), From 2a9ca5e6c07b406de29db4affc122c6de08f8a47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 00:22:17 +0000 Subject: [PATCH 6/6] Fix Black/isort formatting to pass lint CI checks Agent-Logs-Url: https://github.com/thaitien280401-stack/RedditVideoMakerBot/sessions/29d5b341-a15e-4235-bb26-8e2de812f684 Co-authored-by: thaitien280401-stack <271128961+thaitien280401-stack@users.noreply.github.com> --- TTS/engine_wrapper.py | 18 ++++++--- main.py | 25 +++++++----- scheduler/pipeline.py | 15 ++++--- threads/threads_client.py | 60 +++++++++++++++++----------- uploaders/base_uploader.py | 17 +++++--- uploaders/facebook_uploader.py | 9 +++-- uploaders/tiktok_uploader.py | 18 +++++---- uploaders/upload_manager.py | 4 +- uploaders/youtube_uploader.py | 26 +++++++----- utils/settings.py | 18 +++------ video_creation/threads_screenshot.py | 15 +++---- 11 files changed, 133 insertions(+), 92 deletions(-) diff --git a/TTS/engine_wrapper.py b/TTS/engine_wrapper.py index 9020da9..0699bb9 100644 --- a/TTS/engine_wrapper.py +++ b/TTS/engine_wrapper.py @@ -130,11 +130,19 @@ class TTSEngine: subprocess.run( [ - "ffmpeg", "-f", "concat", "-y", - "-hide_banner", "-loglevel", "panic", - "-safe", "0", - "-i", f"{self.path}/list.txt", - "-c", "copy", + "ffmpeg", + "-f", + "concat", + "-y", + "-hide_banner", + "-loglevel", + "panic", + "-safe", + "0", + "-i", + f"{self.path}/list.txt", + "-c", + "copy", f"{self.path}/{idx}.mp3", ], check=False, diff --git a/main.py b/main.py index 320dd8d..138714d 100755 --- a/main.py +++ b/main.py @@ -35,8 +35,7 @@ from utils.id import extract_id __VERSION__ = "4.0.0" -print( - """ +print(""" ████████╗██╗ ██╗██████╗ ███████╗ █████╗ ██████╗ ███████╗ ██╗ ██╗██╗██████╗ ███████╗ ██████╗ ╚══██╔══╝██║ ██║██╔══██╗██╔════╝██╔══██╗██╔══██╗██╔════╝ ██║ ██║██║██╔══██╗██╔════╝██╔═══██╗ ██║ ███████║██████╔╝█████╗ ███████║██║ ██║███████╗ ██║ ██║██║██║ ██║█████╗ ██║ ██║ @@ -50,8 +49,7 @@ print( ██║╚██╔╝██║██╔══██║██╔═██╗ ██╔══╝ ██╔══██╗ Auto-post: TikTok | YouTube | Facebook ██║ ╚═╝ ██║██║ ██║██║ ██╗███████╗██║ ██║ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ -""" -) +""") print_markdown( "### 🇻🇳 Threads Video Maker Bot - Phiên bản Việt Nam\n" "Tạo video tự động từ nội dung Threads và đăng lên TikTok, YouTube, Facebook.\n" @@ -97,6 +95,7 @@ def main_threads(POST_ID=None) -> None: # Lưu title vào lịch sử để tránh tạo trùng lặp from utils.title_history import save_title + title = thread_object.get("thread_title", "") tid = thread_object.get("thread_id", "") if title: @@ -106,6 +105,7 @@ def main_threads(POST_ID=None) -> None: def main_threads_with_upload(POST_ID=None) -> None: """Pipeline đầy đủ: Threads → Video → Upload lên các platform.""" from scheduler.pipeline import run_pipeline + run_pipeline(POST_ID) @@ -160,6 +160,7 @@ def shutdown() -> NoReturn: def parse_args(): """Parse command line arguments.""" import argparse + parser = argparse.ArgumentParser(description="Threads Video Maker Bot - Vietnam Edition") parser.add_argument( "--mode", @@ -177,9 +178,7 @@ def parse_args(): if __name__ == "__main__": if sys.version_info.major != 3 or sys.version_info.minor not in [10, 11, 12]: - print( - "Ứng dụng yêu cầu Python 3.10, 3.11 hoặc 3.12. Vui lòng cài đặt phiên bản phù hợp." - ) + print("Ứng dụng yêu cầu Python 3.10, 3.11 hoặc 3.12. Vui lòng cài đặt phiên bản phù hợp.") sys.exit() args = parse_args() @@ -191,9 +190,9 @@ if __name__ == "__main__": config is False and sys.exit() # Kiểm tra TikTok TTS session - if ( - not settings.config["settings"]["tts"].get("tiktok_sessionid", "") - ) and config["settings"]["tts"]["voice_choice"] == "tiktok": + if (not settings.config["settings"]["tts"].get("tiktok_sessionid", "")) and config["settings"][ + "tts" + ]["voice_choice"] == "tiktok": print_substep( "TikTok TTS cần sessionid! Xem tài liệu để biết cách lấy.", "bold red", @@ -205,6 +204,7 @@ if __name__ == "__main__": # Chế độ lên lịch tự động print_step("🕐 Khởi động chế độ lên lịch tự động...") from scheduler.pipeline import run_scheduled + run_scheduled() elif args.mode == "auto": @@ -234,9 +234,12 @@ if __name__ == "__main__": if args.reddit: # Legacy Reddit mode from prawcore import ResponseException + try: if config["reddit"]["thread"]["post_id"]: - for index, post_id in enumerate(config["reddit"]["thread"]["post_id"].split("+")): + for index, post_id in enumerate( + config["reddit"]["thread"]["post_id"].split("+") + ): index += 1 print_step(f"Đang xử lý post {index}...") main_reddit(post_id) diff --git a/scheduler/pipeline.py b/scheduler/pipeline.py index f98e477..ef520f8 100644 --- a/scheduler/pipeline.py +++ b/scheduler/pipeline.py @@ -81,7 +81,9 @@ def run_pipeline(post_id: Optional[str] = None) -> Optional[str]: make_final_video(number_of_comments, length, thread_object, bg_config) # Tìm file video đã tạo - subreddit = settings.config.get("threads", {}).get("thread", {}).get("channel_name", "threads") + subreddit = ( + settings.config.get("threads", {}).get("thread", {}).get("channel_name", "threads") + ) results_dir = f"./results/{subreddit}" video_path = None if os.path.exists(results_dir): @@ -96,8 +98,7 @@ def run_pipeline(post_id: Optional[str] = None) -> Optional[str]: # Step 6: Upload (nếu cấu hình) upload_config = settings.config.get("uploaders", {}) has_uploaders = any( - upload_config.get(p, {}).get("enabled", False) - for p in ["youtube", "tiktok", "facebook"] + upload_config.get(p, {}).get("enabled", False) for p in ["youtube", "tiktok", "facebook"] ) if has_uploaders and video_path: @@ -166,13 +167,17 @@ def run_scheduled(): return timezone = scheduler_config.get("timezone", "Asia/Ho_Chi_Minh") - cron_expression = scheduler_config.get("cron", "0 */3 * * *") # Mặc định mỗi 3 giờ (8 lần/ngày: 00, 03, 06, 09, 12, 15, 18, 21h) + cron_expression = scheduler_config.get( + "cron", "0 */3 * * *" + ) # Mặc định mỗi 3 giờ (8 lần/ngày: 00, 03, 06, 09, 12, 15, 18, 21h) max_videos_per_day = scheduler_config.get("max_videos_per_day", 8) # Parse cron expression cron_parts = cron_expression.split() if len(cron_parts) != 5: - print_substep("Cron expression không hợp lệ! Format: minute hour day month weekday", style="bold red") + print_substep( + "Cron expression không hợp lệ! Format: minute hour day month weekday", style="bold red" + ) return scheduler = BlockingScheduler(timezone=timezone) diff --git a/threads/threads_client.py b/threads/threads_client.py index dc42cf8..444195b 100644 --- a/threads/threads_client.py +++ b/threads/threads_client.py @@ -16,7 +16,6 @@ from utils.title_history import is_title_used from utils.videos import check_done from utils.voice import sanitize_text - THREADS_API_BASE = "https://graph.threads.net/v1.0" @@ -27,9 +26,11 @@ class ThreadsClient: self.access_token = settings.config["threads"]["creds"]["access_token"] self.user_id = settings.config["threads"]["creds"]["user_id"] self.session = requests.Session() - self.session.headers.update({ - "Authorization": f"Bearer {self.access_token}", - }) + self.session.headers.update( + { + "Authorization": f"Bearer {self.access_token}", + } + ) def _get(self, endpoint: str, params: Optional[dict] = None) -> dict: """Make a GET request to the Threads API.""" @@ -52,10 +53,13 @@ class ThreadsClient: Danh sách các thread objects. """ uid = user_id or self.user_id - data = self._get(f"{uid}/threads", params={ - "fields": "id,media_type,media_url,permalink,text,timestamp,username,shortcode,is_reply,reply_audience", - "limit": limit, - }) + data = self._get( + f"{uid}/threads", + params={ + "fields": "id,media_type,media_url,permalink,text,timestamp,username,shortcode,is_reply,reply_audience", + "limit": limit, + }, + ) return data.get("data", []) def get_thread_replies(self, thread_id: str, limit: int = 50) -> List[dict]: @@ -68,11 +72,14 @@ class ThreadsClient: Returns: Danh sách replies. """ - data = self._get(f"{thread_id}/replies", params={ - "fields": "id,text,timestamp,username,permalink,hide_status", - "limit": limit, - "reverse": "true", - }) + data = self._get( + f"{thread_id}/replies", + params={ + "fields": "id,text,timestamp,username,permalink,hide_status", + "limit": limit, + "reverse": "true", + }, + ) return data.get("data", []) def get_thread_by_id(self, thread_id: str) -> dict: @@ -84,9 +91,12 @@ class ThreadsClient: Returns: Thread object. """ - return self._get(thread_id, params={ - "fields": "id,media_type,media_url,permalink,text,timestamp,username,shortcode", - }) + return self._get( + thread_id, + params={ + "fields": "id,media_type,media_url,permalink,text,timestamp,username,shortcode", + }, + ) def search_threads_by_keyword(self, threads: List[dict], keywords: List[str]) -> List[dict]: """Lọc threads theo từ khóa. @@ -194,7 +204,9 @@ def get_threads_posts(POST_ID: str = None) -> dict: thread_id = thread.get("id", "") thread_text = thread.get("text", "") - thread_url = thread.get("permalink", f"https://www.threads.net/post/{thread.get('shortcode', '')}") + thread_url = thread.get( + "permalink", f"https://www.threads.net/post/{thread.get('shortcode', '')}" + ) thread_username = thread.get("username", "unknown") print_substep(f"Video sẽ được tạo từ: {thread_text[:100]}...", style="bold green") @@ -240,12 +252,14 @@ def get_threads_posts(POST_ID: str = None) -> dict: if len(reply_text) < min_comment_length: continue - content["comments"].append({ - "comment_body": reply_text, - "comment_url": reply.get("permalink", ""), - "comment_id": re.sub(r"[^\w\s-]", "", reply.get("id", "")), - "comment_author": f"@{reply_username}", - }) + content["comments"].append( + { + "comment_body": reply_text, + "comment_url": reply.get("permalink", ""), + "comment_id": re.sub(r"[^\w\s-]", "", reply.get("id", "")), + "comment_author": f"@{reply_username}", + } + ) print_substep( f"Đã lấy nội dung từ Threads thành công! ({len(content.get('comments', []))} replies)", diff --git a/uploaders/base_uploader.py b/uploaders/base_uploader.py index e67b0f1..8dea3d7 100644 --- a/uploaders/base_uploader.py +++ b/uploaders/base_uploader.py @@ -6,7 +6,7 @@ import os import time from abc import ABC, abstractmethod from dataclasses import dataclass, field -from typing import Optional, List +from typing import List, Optional from utils.console import print_step, print_substep @@ -14,6 +14,7 @@ from utils.console import print_step, print_substep @dataclass class VideoMetadata: """Metadata cho video cần upload.""" + file_path: str title: str description: str = "" @@ -65,12 +66,16 @@ class BaseUploader(ABC): True nếu hợp lệ. """ if not os.path.exists(metadata.file_path): - print_substep(f"[{self.platform_name}] File không tồn tại: {metadata.file_path}", style="bold red") + print_substep( + f"[{self.platform_name}] File không tồn tại: {metadata.file_path}", style="bold red" + ) return False file_size = os.path.getsize(metadata.file_path) if file_size == 0: - print_substep(f"[{self.platform_name}] File rỗng: {metadata.file_path}", style="bold red") + print_substep( + f"[{self.platform_name}] File rỗng: {metadata.file_path}", style="bold red" + ) return False if not metadata.title: @@ -114,9 +119,11 @@ class BaseUploader(ABC): style="bold red", ) if attempt < max_retries: - backoff = min(2 ** attempt, 60) # Exponential backoff, max 60s + backoff = min(2**attempt, 60) # Exponential backoff, max 60s print_substep(f"Chờ {backoff}s trước khi thử lại...", style="bold yellow") time.sleep(backoff) - print_substep(f"Upload {self.platform_name} thất bại sau {max_retries} lần thử!", style="bold red") + print_substep( + f"Upload {self.platform_name} thất bại sau {max_retries} lần thử!", style="bold red" + ) return None diff --git a/uploaders/facebook_uploader.py b/uploaders/facebook_uploader.py index 527b7d1..26ae0d1 100644 --- a/uploaders/facebook_uploader.py +++ b/uploaders/facebook_uploader.py @@ -70,7 +70,10 @@ class FacebookUploader(BaseUploader): if "id" in data: self._authenticated = True - print_substep(f"Facebook: Xác thực thành công (Page: {data.get('name', self.page_id)}) ✅", style="bold green") + print_substep( + f"Facebook: Xác thực thành công (Page: {data.get('name', self.page_id)}) ✅", + style="bold green", + ) return True else: print_substep("Facebook: Token không hợp lệ", style="bold red") @@ -96,7 +99,7 @@ class FacebookUploader(BaseUploader): file_size = os.path.getsize(metadata.file_path) - title = metadata.title[:self.MAX_TITLE_LENGTH] + title = metadata.title[: self.MAX_TITLE_LENGTH] description = self._build_description(metadata) # Step 1: Initialize upload session @@ -163,7 +166,7 @@ class FacebookUploader(BaseUploader): "upload_session_id": upload_session_id, "access_token": self.access_token, "title": title, - "description": description[:self.MAX_DESCRIPTION_LENGTH], + "description": description[: self.MAX_DESCRIPTION_LENGTH], } if metadata.schedule_time: diff --git a/uploaders/tiktok_uploader.py b/uploaders/tiktok_uploader.py index 561c44c..40abe69 100644 --- a/uploaders/tiktok_uploader.py +++ b/uploaders/tiktok_uploader.py @@ -58,12 +58,16 @@ class TikTokUploader(BaseUploader): return False try: - response = requests.post(self.TOKEN_URL, json={ - "client_key": client_key, - "client_secret": client_secret, - "grant_type": "refresh_token", - "refresh_token": refresh_token, - }, timeout=30) + response = requests.post( + self.TOKEN_URL, + json={ + "client_key": client_key, + "client_secret": client_secret, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + }, + timeout=30, + ) response.raise_for_status() token_data = response.json() @@ -209,7 +213,7 @@ class TikTokUploader(BaseUploader): hashtag_str = " ".join(f"#{tag}" for tag in metadata.hashtags) parts.append(hashtag_str) caption = " ".join(parts) - return caption[:self.MAX_CAPTION_LENGTH] + return caption[: self.MAX_CAPTION_LENGTH] @staticmethod def _map_privacy(privacy: str) -> str: diff --git a/uploaders/upload_manager.py b/uploaders/upload_manager.py index e6c46e4..9b8c63c 100644 --- a/uploaders/upload_manager.py +++ b/uploaders/upload_manager.py @@ -6,9 +6,9 @@ import os from typing import Dict, List, Optional from uploaders.base_uploader import BaseUploader, VideoMetadata -from uploaders.youtube_uploader import YouTubeUploader -from uploaders.tiktok_uploader import TikTokUploader from uploaders.facebook_uploader import FacebookUploader +from uploaders.tiktok_uploader import TikTokUploader +from uploaders.youtube_uploader import YouTubeUploader from utils import settings from utils.console import print_step, print_substep diff --git a/uploaders/youtube_uploader.py b/uploaders/youtube_uploader.py index 10b4a82..c4a05e1 100644 --- a/uploaders/youtube_uploader.py +++ b/uploaders/youtube_uploader.py @@ -7,8 +7,8 @@ Yêu cầu: - Scopes: https://www.googleapis.com/auth/youtube.upload """ -import os import json +import os import time from typing import Optional @@ -63,12 +63,16 @@ class YouTubeUploader(BaseUploader): return False try: - response = requests.post(self.TOKEN_URL, data={ - "client_id": client_id, - "client_secret": client_secret, - "refresh_token": refresh_token, - "grant_type": "refresh_token", - }, timeout=30) + response = requests.post( + self.TOKEN_URL, + data={ + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + }, + timeout=30, + ) response.raise_for_status() token_data = response.json() @@ -93,7 +97,7 @@ class YouTubeUploader(BaseUploader): if not self.access_token: return None - title = metadata.title[:self.MAX_TITLE_LENGTH] + title = metadata.title[: self.MAX_TITLE_LENGTH] description = self._build_description(metadata) tags = metadata.tags or [] @@ -106,7 +110,7 @@ class YouTubeUploader(BaseUploader): video_metadata = { "snippet": { "title": title, - "description": description[:self.MAX_DESCRIPTION_LENGTH], + "description": description[: self.MAX_DESCRIPTION_LENGTH], "tags": tags, "categoryId": self._get_category_id(metadata.category), "defaultLanguage": metadata.language, @@ -168,7 +172,9 @@ class YouTubeUploader(BaseUploader): video_id = video_data.get("id", "") if not video_id: - print_substep("YouTube: Upload thành công nhưng không lấy được video ID", style="bold yellow") + print_substep( + "YouTube: Upload thành công nhưng không lấy được video ID", style="bold yellow" + ) return None # Step 3: Upload thumbnail if available diff --git a/utils/settings.py b/utils/settings.py index 085a963..c9a0905 100755 --- a/utils/settings.py +++ b/utils/settings.py @@ -152,10 +152,8 @@ def check_toml(template_file, config_file) -> Tuple[bool, Dict]: try: config = toml.load(config_file) except toml.TomlDecodeError: - console.print( - f"""[blue]Couldn't read {config_file}. -Overwrite it?(y/n)""" - ) + console.print(f"""[blue]Couldn't read {config_file}. +Overwrite it?(y/n)""") if not input().startswith("y"): print("Unable to read config, and not allowed to overwrite it. Giving up.") return False @@ -169,10 +167,8 @@ Overwrite it?(y/n)""" ) return False except FileNotFoundError: - console.print( - f"""[blue]Couldn't find {config_file} -Creating it now.""" - ) + console.print(f"""[blue]Couldn't find {config_file} +Creating it now.""") try: with open(config_file, "x") as f: f.write("") @@ -183,16 +179,14 @@ Creating it now.""" ) return False - console.print( - """\ + console.print("""\ [blue bold]############################### # # # Checking TOML configuration # # # ############################### If you see any prompts, that means that you have unset/incorrectly set variables, please input the correct values.\ -""" - ) +""") crawl(template, check_vars) with open(config_file, "w") as f: toml.dump(config, f) diff --git a/video_creation/threads_screenshot.py b/video_creation/threads_screenshot.py index af42728..f44fbf8 100644 --- a/video_creation/threads_screenshot.py +++ b/video_creation/threads_screenshot.py @@ -17,7 +17,6 @@ from rich.progress import track from utils import settings from utils.console import print_step, print_substep - # Threads color themes THEMES = { "dark": { @@ -42,11 +41,11 @@ THEMES = { # Avatar color palette for comments AVATAR_COLORS = [ - (88, 101, 242), # Blue - (237, 66, 69), # Red - (87, 242, 135), # Green - (254, 231, 92), # Yellow - (235, 69, 158), # Pink + (88, 101, 242), # Blue + (237, 66, 69), # Red + (87, 242, 135), # Green + (254, 231, 92), # Yellow + (235, 69, 158), # Pink ] @@ -329,9 +328,7 @@ def get_screenshots_of_threads_posts(thread_object: dict, screenshot_num: int): else: # Comment mode - tạo hình cho từng reply comments = thread_object.get("comments", [])[:screenshot_num] - for idx, comment in enumerate( - track(comments, "Đang tạo hình ảnh replies...") - ): + for idx, comment in enumerate(track(comments, "Đang tạo hình ảnh replies...")): if idx >= screenshot_num: break