- 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>pull/2482/head
parent
14a8f6425c
commit
00a37231b8
@ -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.")
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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")
|
||||
@ -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",
|
||||
]
|
||||
@ -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")
|
||||
@ -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)" }
|
||||
|
||||
@ -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")
|
||||
Loading…
Reference in new issue