import tkinter as tk
from tkinter import filedialog, messagebox, scrolledtext
from tkinter.ttk import Combobox
import threading
import os
import csv
import requests
import isodate
from datetime import datetime
from urllib.parse import urlparse
from googleapiclient.discovery import build
API_KEY = 'AIzaSyA-_OOaPgMbzoou8GLuEn9-a3AWMhe2P8U'
# --- Phân tích đầu vào ---
def resolve_channel_id(youtube, input_value, log_fn=None):
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
import re, time
from urllib.parse import urlparse
input_value = input_value.strip()
if input_value.startswith("http"):
parsed = urlparse(input_value)
path = parsed.path
if '/channel/' in path:
return path.split('/channel/')[-1]
elif '/@' in path:
try:
options = Options()
options.add_argument("--headless")
options.add_argument("--disable-gpu")
options.add_argument("--log-level=3")
options.add_argument("--window-size=1920,1080")
if log_fn:
log_fn("🔎 Đang tìm kiếm ID kênh YouTube từ handle...")
driver = webdriver.Chrome(options=options)
driver.get(input_value)
time.sleep(2)
html = driver.page_source
driver.quit()
match = re.search(r'https://www\.youtube\.com/channel/(UC[\w-]+)', html)
if match:
return match.group(1)
else:
raise Exception("Không xác định được Channel ID từ handle.")
except Exception as e:
raise Exception(f"Không thể truy cập handle: {e}")
else:
raise Exception("❌ Link không hợp lệ.")
else:
return input_value
# --- API ---
def get_channel_title(youtube, channel_id):
res = youtube.channels().list(part='snippet', id=channel_id).execute()
if 'items' not in res or not res['items']:
raise Exception("❌ Không tìm thấy thông tin kênh YouTube.")
return res['items'][0]['snippet']['title']
def get_uploads_playlist_id(youtube, channel_id):
res = youtube.channels().list(part='contentDetails', id=channel_id).execute()
return res['items'][0]['contentDetails']['relatedPlaylists']['uploads']
def get_video_details(youtube, video_id):
res = youtube.videos().list(part='snippet,contentDetails', id=video_id).execute()
item = res['items'][0]
snippet = item['snippet']
raw_duration = item['contentDetails']['duration']
duration = parse_duration(raw_duration)
return {
'title': snippet['title'],
'description': snippet.get('description', '').replace('\r', '').strip(),
'publishedAt': snippet['publishedAt'],
'url': f'https://www.youtube.com/watch?v={video_id}',
'duration': duration
}
def get_all_videos(youtube, playlist_id, log_fn, days_limit=None):
from dateutil import parser
from datetime import timedelta
videos = []
next_page = None
while True:
res = youtube.playlistItems().list(
part='contentDetails',
playlistId=playlist_id,
maxResults=50,
pageToken=next_page
).execute()
for item in res['items']:
vid = item['contentDetails']['videoId']
details = get_video_details(youtube, vid)
if days_limit is not None:
published_date = parser.parse(details['publishedAt']).date()
if published_date < (datetime.utcnow().date() - timedelta(days=days_limit)):
break
videos.append(details)
log_fn(f"✔ {details['title']}")
next_page = res.get('nextPageToken')
if not next_page:
break
return videos
def parse_duration(duration_str):
try:
duration = isodate.parse_duration(duration_str)
total_seconds = int(duration.total_seconds())
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
seconds = total_seconds % 60
return f"{hours:02}:{minutes:02}:{seconds:02}"
except Exception:
return "00:00:00"
def save_to_file(videos, path, format, channel_title, log_fn):
safe_title = "".join(c for c in channel_title if c.isalnum() or c in "-_ ").strip().replace(" ", "_")
date_str = datetime.now().strftime('%Y%m%d')
filename = f"{safe_title}-{date_str}.{format}"
file_path = os.path.join(path, filename)
if format == 'txt':
with open(file_path, 'w', encoding='utf-8') as f:
for v in videos:
pub_date = v['publishedAt'][:10]
description = v['description'].replace('\r', '').replace('\n', ' ')
f.write(f"{v['url']}|{v['title']}|{description}|{pub_date}|{v['duration']}\n")
elif format == 'csv':
with open(file_path, 'w', encoding='utf-8-sig', newline='') as f:
writer = csv.writer(f, quoting=csv.QUOTE_ALL)
writer.writerow(['Link', 'Tiêu đề', 'Mô tả', 'Ngày xuất bản', 'Thời lượng'])
for v in videos:
pub_date = v['publishedAt'][:10]
writer.writerow([v['url'], v['title'], v['description'], pub_date, v['duration']])
else:
log_fn(f"❌ Định dạng không hợp lệ: {format}")
return
log_fn(f"\n✅ Đã trích xuất được {len(videos)} video theo yêu cầu của bạn.\n\n✅ Tập tin được lưu vào {file_path}")
def start_scrape(input_value, output_dir, format, log_fn, button, days_limit=None):
def task():
try:
log_fn("🔍 Đang kết nối với YouTube API...")
youtube = build('youtube', 'v3', developerKey=API_KEY)
channel_id = resolve_channel_id(youtube, input_value, log_fn)
log_fn(f"🔎 ID Kênh YouTube : {channel_id}\n")
channel_title = get_channel_title(youtube, channel_id)
log_fn(f"📺 Tên Kênh YouTube: {channel_title}")
playlist_id = get_uploads_playlist_id(youtube, channel_id)
log_fn(f"📁 Playlist ID : {playlist_id}\n")
videos = get_all_videos(youtube, playlist_id, log_fn, days_limit)
save_to_file(videos, output_dir, format, channel_title, log_fn)
log_fn("\n🌝 Đã Hoàn Tất.")
except Exception as e:
log_fn(f"❌ Lỗi: {e}")
finally:
button.config(state='normal')
threading.Thread(target=task).start()
def center_window(root, width=900, height=600):
# Lấy kích thước màn hình
screen_width = root.winfo_screenwidth()
screen_height = root.winfo_screenheight()
# Tính vị trí bắt đầu trục ngang và trục đứng
aaaxxx = (screen_width // 2) - (width // 2)
bbbyyy = (screen_height // 2) - (height // 2) - (40)
# Đặt geometry
root.geometry(f"{width}x{height}+{aaaxxx}+{bbbyyy}")
def main_gui():
root = tk.Tk()
root.title("Youtube Channel Scratch - DarkCorners")
root.resizable(False, False)
center_window(root, 900, 600)
# Hàng 1 : Tiêu đề phần mềm
tk.Label(root, text="Youtube Channel Scratch - DarkCorners", font=("Arial", 18, "bold")).pack(pady=10)
# Hàng 2 : Khung thông tin thư mục
frame_top = tk.Frame(root)
frame_top.pack(pady=10, padx=10, fill='x')
# Hàng 2 Trái : Youtube Channel
frame_channel_wrap = tk.Frame(frame_top, width=400, height=60)
frame_channel_wrap.pack_propagate(False)
frame_channel_wrap.pack(side=tk.LEFT, padx=(0, 5), expand=True, fill='both')
lf_channel = tk.LabelFrame(frame_channel_wrap, text="Youtube Channel", font=("Arial", 10, "bold"))
lf_channel.pack(fill='both', expand=True)
frame_channel = tk.Frame(lf_channel)
frame_channel.pack(fill='x', pady=5, padx=5)
tk.Label(frame_channel, text="ID / Link", font=("Arial", 10)).pack(side=tk.LEFT)
channel_input_var = tk.StringVar()
tk.Entry(frame_channel, textvariable=channel_input_var).pack(side=tk.LEFT, fill='x', expand=True, padx=(5, 0))
# Hàng 2 Phải : Thư Mục Lưu Tệp
frame_folder_wrap = tk.Frame(frame_top, width=400, height=60)
frame_folder_wrap.pack_propagate(False)
frame_folder_wrap.pack(side=tk.RIGHT, padx=(5, 0), expand=True, fill='both')
lf_folder = tk.LabelFrame(frame_folder_wrap, text="Chọn Thư Mục Lưu Tệp", font=("Arial", 10, "bold"))
lf_folder.pack(fill='both', expand=True)
frame_folder = tk.Frame(lf_folder)
frame_folder.pack(fill='x', pady=5, padx=5)
folder_path = tk.StringVar()
tk.Entry(frame_folder, textvariable=folder_path).pack(side=tk.LEFT, fill='x', expand=True)
tk.Button(frame_folder, text="Chọn", command=lambda: folder_path.set(filedialog.askdirectory())).pack(side=tk.LEFT, padx=(5, 0))
# Hàng 3 : Tùy Chỉnh & Điều Khiển
frame_middle = tk.Frame(root)
frame_middle.pack(pady=10, padx=10, fill='x')
# Hàng 3 Trái : Tùy Chỉnh
frame_format_wrap = tk.Frame(frame_middle, width=400, height=60)
frame_format_wrap.pack_propagate(False)
frame_format_wrap.pack(side=tk.LEFT, padx=(0, 5), expand=True, fill='both')
lf_format = tk.LabelFrame(frame_format_wrap, text="Tùy Chỉnh", font=("Arial", 10, "bold"))
lf_format.pack(fill='both', expand=True)
frame_format = tk.Frame(lf_format)
frame_format.pack(fill='x', pady=5, padx=5)
tk.Label(frame_format, text="Định Dạng Tệp", font=("Arial", 10)).pack(side=tk.LEFT)
format_var = tk.StringVar(value='csv')
format_combo = Combobox(frame_format, textvariable=format_var, values=['csv', 'txt'], state='readonly', width=8, font=("Arial", 10))
format_combo.pack(side=tk.LEFT, padx=(10, 20))
tk.Label(frame_format, text="Giới Hạn Ngày", font=("Arial", 10)).pack(side=tk.LEFT)
date_limit_var = tk.StringVar(value='Toàn Bộ')
date_limit_combo = Combobox(frame_format, textvariable=date_limit_var, values=['07 Ngày', '10 Ngày', '20 Ngày', '30 Ngày', 'Toàn Bộ'], state='readonly', width=8, font=("Arial", 10))
date_limit_combo.pack(side=tk.LEFT, padx=(10, 0))
# Hàng 3 Phải : Điều Khiển
frame_control_wrap = tk.Frame(frame_middle, width=400, height=60)
frame_control_wrap.pack_propagate(False)
frame_control_wrap.pack(side=tk.RIGHT, padx=(5, 0), expand=True, fill='both')
lf_control = tk.LabelFrame(frame_control_wrap, text="Điều Khiển", font=("Arial", 10, "bold"))
lf_control.pack(fill='both', expand=True)
frame_control = tk.Frame(lf_control)
frame_control.pack(pady=5, padx=5, fill='x')
def log(message):
log_text.insert(tk.END, message + "\n")
log_text.see(tk.END)
def on_start():
input_val = channel_input_var.get().strip()
out_dir = folder_path.get().strip()
file_format = format_var.get()
if not input_val:
messagebox.showerror("Lỗi", "Vui lòng nhập Channel ID hoặc link.")
return
if not out_dir:
messagebox.showerror("Lỗi", "Vui lòng chọn thư mục lưu file.")
return
start_button.config(state='disabled')
log_text.delete(1.0, tk.END)
days_str = date_limit_var.get()
days_map = {
'07 Ngày': 7,
'10 Ngày': 10,
'20 Ngày': 20,
'30 Ngày': 30
}
days_limit = days_map.get(days_str, None)
start_scrape(input_val, out_dir, file_format, log, start_button, days_limit)
start_button = tk.Button(frame_control, text="▶ Bắt Đầu", command=on_start, font=("Arial", 10), bg="#4CAF50", fg="white", relief="ridge", width=12)
start_button.pack(side=tk.LEFT, padx=10)
tk.Button(frame_control, text="✖ Thoát", command=root.destroy, font=("Arial", 10), bg="#f44336", fg="white", relief="ridge", width=10).pack(side=tk.LEFT, padx=10)
# Hàng 4 : Lịch Trình Xử Lý
frame_log = tk.LabelFrame(root, text="Lịch Trình Xử Lý", font=("Arial", 10, "bold"))
frame_log.pack(padx=10, pady=5, fill='both', expand=True)
log_text = scrolledtext.ScrolledText(frame_log, font=("Consolas", 10))
log_text.pack(fill='both', expand=True, padx=5, pady=5)
root.mainloop()
if __name__ == "__main__":
main_gui()