Spaces:
Sleeping
Sleeping
| --- | |
| import AdminLayout from '../../layouts/AdminLayout.astro'; | |
| --- | |
| <AdminLayout title="Thu thập phim KKPhim"> | |
| <div class="mb-12"> | |
| <h1 class="text-4xl font-black tracking-tighter text-white">Hệ thống Thu thập phim (Crawler)</h1> | |
| <p class="mt-2 text-gray-500">Thu thập phim tự động từ nguồn dữ liệu KKPhim trực tiếp vào cơ sở dữ liệu tệp tin cục bộ.</p> | |
| </div> | |
| <div class="grid grid-cols-1 gap-8 lg:grid-cols-12"> | |
| <!-- Cấu hình thu thập --> | |
| <div class="lg:col-span-5 space-y-8"> | |
| <!-- Tabs cấu hình --> | |
| <div class="glass rounded-3xl p-8 border border-white/5 space-y-6"> | |
| <div class="flex border-b border-white/5 pb-1 gap-2"> | |
| <button id="tab-url-btn" class="pb-4 text-[10px] sm:text-xs font-black uppercase tracking-widest text-primary border-b-2 border-primary transition-all whitespace-nowrap cursor-pointer">Theo URL/Slug</button> | |
| <button id="tab-batch-btn" class="pb-4 text-[10px] sm:text-xs font-black uppercase tracking-widest text-gray-500 border-b-2 border-transparent hover:text-white transition-all whitespace-nowrap cursor-pointer">Hàng loạt (Batch)</button> | |
| <button id="tab-all-btn" class="pb-4 text-[10px] sm:text-xs font-black uppercase tracking-widest text-gray-500 border-b-2 border-transparent hover:text-white transition-all whitespace-nowrap cursor-pointer">Toàn bộ (All)</button> | |
| </div> | |
| <!-- Section 1: URL Crawl --> | |
| <div id="section-url" class="space-y-6"> | |
| <div class="flex flex-col gap-2"> | |
| <label class="text-xs font-black uppercase tracking-widest text-gray-400">Danh sách URL hoặc Slug phim</label> | |
| <p class="text-[10px] text-gray-500">Mỗi dòng là một link phim KKPhim hoặc slug phim. Ví dụ: <br/><code>https://kkphim.vip/phim/dau-pha-thuong-khung</code> hoặc <code>tay-du-ky</code></p> | |
| <textarea | |
| id="crawl-urls" | |
| rows="8" | |
| placeholder="Nhập danh sách link phim tại đây..." | |
| class="mt-2 w-full bg-[#12141d] border border-white/5 rounded-2xl p-4 text-sm text-white focus:outline-none focus:border-primary/50 font-mono transition-all" | |
| ></textarea> | |
| </div> | |
| <!-- Trạng thái detect URL thời gian thực --> | |
| <div class="flex items-center justify-between bg-white/5 rounded-2xl px-6 py-4 border border-white/5"> | |
| <span class="text-xs font-bold text-gray-400">Trạng thái phân tích:</span> | |
| <div class="flex gap-4"> | |
| <span class="text-xs font-black text-emerald-400" id="detect-valid">0 Hợp lệ</span> | |
| <span class="text-xs font-black text-red-400" id="detect-invalid">0 Không hợp lệ</span> | |
| </div> | |
| </div> | |
| <button id="start-url-crawl-btn" class="w-full flex items-center justify-center gap-2 rounded-2xl bg-primary py-4 text-xs font-black uppercase tracking-widest text-dark hover:scale-102 active:scale-98 transition-all shadow-[0_0_20px_var(--primary)] cursor-pointer"> | |
| <i class="fas fa-spider"></i> Bắt đầu thu thập | |
| </button> | |
| </div> | |
| <!-- Section 2: Batch Crawl --> | |
| <div id="section-batch" class="space-y-6 hidden"> | |
| <div class="grid grid-cols-2 gap-4"> | |
| <div class="flex flex-col gap-2"> | |
| <label class="text-xs font-black uppercase tracking-widest text-gray-400">Trang bắt đầu</label> | |
| <input | |
| type="number" | |
| id="batch-page-start" | |
| value="1" | |
| min="1" | |
| class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-6 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" | |
| /> | |
| </div> | |
| <div class="flex flex-col gap-2"> | |
| <label class="text-xs font-black uppercase tracking-widest text-gray-400">Trang kết thúc</label> | |
| <input | |
| type="number" | |
| id="batch-page-end" | |
| value="1" | |
| min="1" | |
| class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-6 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all" | |
| /> | |
| </div> | |
| </div> | |
| <p class="text-[10px] text-gray-500">Quét toàn bộ phim mới cập nhật trên các trang được chọn. Mỗi trang có khoảng 30 phim.</p> | |
| <button id="start-batch-crawl-btn" class="w-full flex items-center justify-center gap-2 rounded-2xl bg-primary py-4 text-xs font-black uppercase tracking-widest text-dark hover:scale-102 active:scale-98 transition-all shadow-[0_0_20px_var(--primary)] cursor-pointer"> | |
| <i class="fas fa-layer-group"></i> Bắt đầu quét hàng loạt | |
| </button> | |
| </div> | |
| <!-- Section 3: All Crawl --> | |
| <div id="section-all" class="space-y-6 hidden"> | |
| <div class="bg-amber-500/10 border border-amber-500/20 rounded-2xl p-5 text-amber-400 space-y-2"> | |
| <h4 class="text-xs font-black uppercase tracking-widest flex items-center gap-1.5"><i class="fas fa-exclamation-triangle"></i> Lưu ý quan trọng</h4> | |
| <p class="text-[10.5px] leading-relaxed opacity-90">Hệ thống sẽ quét toàn bộ dữ liệu phim trên hệ thống API KKPhim (22.826+ phim). Tiến trình này sẽ cào tuần tự, tự động cập nhật tập phim mới hoặc thêm mới phim chưa tồn tại.</p> | |
| </div> | |
| <button id="start-all-crawl-btn" class="w-full flex items-center justify-center gap-2 rounded-2xl bg-primary py-4 text-xs font-black uppercase tracking-widest text-dark hover:scale-102 active:scale-98 transition-all shadow-[0_0_20px_var(--primary)] cursor-pointer"> | |
| <i class="fas fa-database"></i> Bắt đầu cào toàn bộ (All) | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Card 2: Crawl theo Quốc gia --> | |
| <div class="glass rounded-3xl p-8 border border-white/5 space-y-6"> | |
| <div class="flex items-center gap-3 border-b border-white/5 pb-4"> | |
| <div class="h-8 w-8 rounded-xl bg-primary/10 flex items-center justify-center text-primary border border-primary/20"> | |
| <i class="fas fa-globe"></i> | |
| </div> | |
| <div> | |
| <h3 class="text-xs font-black uppercase tracking-widest text-white">Cào theo quốc gia</h3> | |
| <p class="text-[10px] text-gray-500 mt-0.5">Crawl phim từ KKPhim theo từng quốc gia cụ thể</p> | |
| </div> | |
| </div> | |
| <div class="space-y-4"> | |
| <div class="flex flex-col gap-2"> | |
| <label class="text-xs font-black uppercase tracking-widest text-gray-400">Chọn quốc gia</label> | |
| <select id="country-selector" class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-6 py-4 text-sm text-white focus:outline-none focus:border-primary/50 font-bold transition-all cursor-pointer"> | |
| <option value="trung-quoc">Trung Quốc</option> | |
| <option value="han-quoc">Hàn Quốc</option> | |
| <option value="nhat-ban">Nhật Bản</option> | |
| <option value="thai-lan">Thái Lan</option> | |
| <option value="au-my">Âu Mỹ</option> | |
| <option value="viet-nam">Việt Nam</option> | |
| <option value="an-do">Ấn Độ</option> | |
| <option value="hong-kong">Hồng Kông</option> | |
| <option value="dai-loan">Đài Loan</option> | |
| <option value="anh">Anh</option> | |
| <option value="phap">Pháp</option> | |
| <option value="canada">Canada</option> | |
| <option value="duc">Đức</option> | |
| <option value="tay-ban-nha">Tây Ban Nha</option> | |
| <option value="tho-nhi-ky">Thổ Nhĩ Kỳ</option> | |
| <option value="philippines">Philippines</option> | |
| <option value="y">Ý</option> | |
| <option value="dan-mach">Đan Mạch</option> | |
| <option value="mexico">Mexico</option> | |
| <option value="malaysia">Malaysia</option> | |
| <option value="uc">Úc</option> | |
| <option value="brazil">Brazil</option> | |
| <option value="bi">Bỉ</option> | |
| <option value="na-uy">Na Uy</option> | |
| <option value="nam-phi">Nam Phi</option> | |
| <option value="ba-lan">Ba Lan</option> | |
| </select> | |
| </div> | |
| <div class="grid grid-cols-2 gap-4"> | |
| <div class="flex flex-col gap-2"> | |
| <label class="text-xs font-black uppercase tracking-widest text-gray-400">Trang bắt đầu</label> | |
| <input | |
| type="number" | |
| id="country-page-start" | |
| value="1" | |
| min="1" | |
| class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-6 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all font-bold" | |
| /> | |
| </div> | |
| <div class="flex flex-col gap-2"> | |
| <label class="text-xs font-black uppercase tracking-widest text-gray-400">Trang kết thúc</label> | |
| <input | |
| type="number" | |
| id="country-page-end" | |
| value="1" | |
| min="1" | |
| class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-6 py-4 text-sm text-white focus:outline-none focus:border-primary/50 transition-all font-bold" | |
| /> | |
| </div> | |
| </div> | |
| <p class="text-[10px] text-gray-500">Hệ thống sẽ tải toàn bộ danh sách phim của quốc gia này trên các trang được chọn trước khi cào chi tiết.</p> | |
| <button id="start-country-crawl-btn" class="w-full flex items-center justify-center gap-2 rounded-2xl bg-primary py-4 text-xs font-black uppercase tracking-widest text-dark hover:scale-102 active:scale-98 transition-all shadow-[0_0_20px_var(--primary)] cursor-pointer font-bold"> | |
| <i class="fas fa-globe-asia"></i> Quét theo quốc gia | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Card 3: Crawl theo Thể loại --> | |
| <div class="glass rounded-3xl p-8 border border-white/5 space-y-6"> | |
| <div class="flex items-center gap-3 border-b border-white/5 pb-4"> | |
| <div class="h-8 w-8 rounded-xl bg-purple-500/10 flex items-center justify-center text-purple-400 border border-purple-500/20"> | |
| <i class="fas fa-tags"></i> | |
| </div> | |
| <div> | |
| <h3 class="text-xs font-black uppercase tracking-widest text-white">Cào theo thể loại</h3> | |
| <p class="text-[10px] text-gray-500 mt-0.5">Crawl phim từ KKPhim theo từng thể loại cụ thể</p> | |
| </div> | |
| </div> | |
| <div class="space-y-4"> | |
| <div class="flex flex-col gap-2"> | |
| <label class="text-xs font-black uppercase tracking-widest text-gray-400">Chọn thể loại</label> | |
| <select id="genre-selector" class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-6 py-4 text-sm text-white focus:outline-none focus:border-purple-500/50 font-bold transition-all cursor-pointer"> | |
| <option value="hanh-dong">Hành Động</option> | |
| <option value="tinh-cam">Tình Cảm</option> | |
| <option value="hai-huoc">Hài Hước</option> | |
| <option value="co-trang">Cổ Trang</option> | |
| <option value="tam-ly">Tâm Lý</option> | |
| <option value="hinh-su">Hình Sự</option> | |
| <option value="chien-tranh">Chiến Tranh</option> | |
| <option value="the-thao">Thể Thao</option> | |
| <option value="vo-thuat">Võ Thuật</option> | |
| <option value="vien-tuong">Viễn Tưởng</option> | |
| <option value="phi-phieu-luu">Phiêu Lưu</option> | |
| <option value="khoa-hoc">Khoa Học</option> | |
| <option value="kinh-di">Kinh Dị</option> | |
| <option value="am-nhac">Âm Nhạc</option> | |
| <option value="phim-chieu-rap">Phim Chiếu Rạp</option> | |
| <option value="hoat-hinh">Hoạt Hình (Anime)</option> | |
| <option value="phim-le">Phim Lẻ</option> | |
| <option value="phim-bo">Phim Bộ</option> | |
| <option value="tv-shows">TV Shows</option> | |
| <option value="than-thoai">Thần Thoại</option> | |
| <option value="tai-lieu">Tài Liệu</option> | |
| <option value="gia-dinh">Gia Đình</option> | |
| <option value="chinh-kich">Chính Kịch</option> | |
| <option value="bi-an">Bí Ẩn</option> | |
| <option value="hoc-duong">Học Đường</option> | |
| <option value="kinh-dien">Kinh Điển</option> | |
| </select> | |
| </div> | |
| <div class="grid grid-cols-2 gap-4"> | |
| <div class="flex flex-col gap-2"> | |
| <label class="text-xs font-black uppercase tracking-widest text-gray-400">Trang bắt đầu</label> | |
| <input | |
| type="number" | |
| id="genre-page-start" | |
| value="1" | |
| min="1" | |
| class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-6 py-4 text-sm text-white focus:outline-none focus:border-purple-500/50 transition-all font-bold" | |
| /> | |
| </div> | |
| <div class="flex flex-col gap-2"> | |
| <label class="text-xs font-black uppercase tracking-widest text-gray-400">Trang kết thúc</label> | |
| <input | |
| type="number" | |
| id="genre-page-end" | |
| value="1" | |
| min="1" | |
| class="w-full bg-[#12141d] border border-white/5 rounded-2xl px-6 py-4 text-sm text-white focus:outline-none focus:border-purple-500/50 transition-all font-bold" | |
| /> | |
| </div> | |
| </div> | |
| <p class="text-[10px] text-gray-500">Hệ thống sẽ tải toàn bộ danh sách phim của thể loại này trên các trang được chọn trước khi cào chi tiết.</p> | |
| <button id="start-genre-crawl-btn" class="w-full flex items-center justify-center gap-2 rounded-2xl bg-purple-500 py-4 text-xs font-black uppercase tracking-widest text-white hover:scale-102 active:scale-98 transition-all shadow-[0_0_20px_rgba(168,85,247,0.4)] cursor-pointer font-bold"> | |
| <i class="fas fa-tags"></i> Quét theo thể loại | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Tiến trình & Nhật ký --> | |
| <div class="lg:col-span-7 space-y-6"> | |
| <!-- Thẻ trạng thái tiến trình --> | |
| <div class="glass rounded-3xl p-8 border border-white/5 space-y-6"> | |
| <div class="flex items-center justify-between"> | |
| <h3 class="text-lg font-black text-white">Trạng thái tiến trình</h3> | |
| <span class="text-[10px] font-black uppercase tracking-widest bg-white/5 border border-white/5 px-3 py-1 rounded-xl text-gray-400" id="crawl-status-badge">Đang chờ lệnh</span> | |
| </div> | |
| <!-- Progress bar --> | |
| <div class="space-y-2"> | |
| <div class="flex items-center justify-between text-xs text-gray-400"> | |
| <span id="current-movie-name" class="font-bold truncate max-w-xs">Chưa có phim nào đang xử lý...</span> | |
| <span id="progress-percent" class="font-mono font-black">0%</span> | |
| </div> | |
| <div class="h-3 w-full bg-[#12141d] rounded-full overflow-hidden border border-white/5"> | |
| <div id="progress-bar-inner" class="h-full w-0 bg-primary shadow-[0_0_15px_var(--primary)] transition-all duration-300"></div> | |
| </div> | |
| </div> | |
| <!-- Confirmation Action Panel --> | |
| <div id="confirm-panel" class="hidden bg-white/[0.02] rounded-2xl p-6 border border-primary/20 space-y-4"> | |
| <div class="flex items-center gap-3"> | |
| <div class="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center text-primary border border-primary/20"> | |
| <i class="fas fa-list-ul"></i> | |
| </div> | |
| <div> | |
| <h4 class="text-xs font-black uppercase tracking-widest text-white">Đã quét xong danh sách phim</h4> | |
| <p class="text-[10px] text-gray-400 mt-0.5" id="confirm-panel-subtitle">Sẵn sàng cào chi tiết 0 phim.</p> | |
| </div> | |
| </div> | |
| <div class="flex gap-3"> | |
| <button id="cancel-crawl-btn" class="flex-1 py-3.5 px-4 rounded-xl border border-white/5 text-gray-400 hover:text-white hover:bg-white/5 transition-all text-xs font-black uppercase tracking-widest cursor-pointer text-center">Hủy bỏ</button> | |
| <button id="confirm-crawl-btn" class="flex-[2] py-3.5 px-4 rounded-xl bg-primary text-dark hover:scale-102 active:scale-98 transition-all text-xs font-black uppercase tracking-widest font-bold shadow-[0_0_15px_var(--primary)] cursor-pointer flex items-center justify-center gap-2"> | |
| <i class="fas fa-play"></i> Xác nhận & Cào chi tiết | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Active Crawl Controls --> | |
| <div id="active-crawl-controls" class="hidden flex gap-3"> | |
| <button id="pause-resume-btn" class="flex-1 py-3.5 px-4 rounded-xl bg-amber-500/20 border border-amber-500/30 text-amber-400 hover:bg-amber-500/30 hover:scale-102 active:scale-98 transition-all text-xs font-black uppercase tracking-widest cursor-pointer flex items-center justify-center gap-2 font-bold"> | |
| <i class="fas fa-pause"></i> Tạm dừng cào | |
| </button> | |
| <button id="stop-crawl-btn" class="flex-1 py-3.5 px-4 rounded-xl bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30 hover:scale-102 active:scale-98 transition-all text-xs font-black uppercase tracking-widest cursor-pointer flex items-center justify-center gap-2 font-bold"> | |
| <i class="fas fa-stop"></i> Hủy tiến trình | |
| </button> | |
| </div> | |
| <!-- Thống kê nhanh số lượng --> | |
| <div class="grid grid-cols-3 gap-4"> | |
| <div class="bg-[#12141d]/50 rounded-2xl p-4 border border-emerald-500/10 text-center"> | |
| <span class="text-[9px] font-black uppercase tracking-widest text-gray-500 block">Thành công</span> | |
| <span class="text-2xl font-black text-emerald-400 block mt-1" id="stats-success">0</span> | |
| </div> | |
| <div class="bg-[#12141d]/50 rounded-2xl p-4 border border-amber-500/10 text-center"> | |
| <span class="text-[9px] font-black uppercase tracking-widest text-gray-500 block">Bỏ qua</span> | |
| <span class="text-2xl font-black text-amber-400 block mt-1" id="stats-skipped">0</span> | |
| </div> | |
| <div class="bg-[#12141d]/50 rounded-2xl p-4 border border-red-500/10 text-center"> | |
| <span class="text-[9px] font-black uppercase tracking-widest text-gray-500 block">Thất bại</span> | |
| <span class="text-2xl font-black text-red-400 block mt-1" id="stats-failed">0</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Bảng Tiến Trình Chi Tiết --> | |
| <div class="glass rounded-3xl p-6 border border-white/5 space-y-4"> | |
| <div class="flex items-center justify-between border-b border-white/5 pb-4"> | |
| <span class="text-xs font-black uppercase tracking-widest text-gray-400"><i class="fas fa-table mr-2"></i> Bảng tiến trình chi tiết</span> | |
| <span class="text-[10px] font-mono text-gray-500" id="table-count">Chờ chạy...</span> | |
| </div> | |
| <!-- Bulk Action Bar --> | |
| <div id="bulk-action-bar" class="hidden flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 bg-white/[0.02] p-4 rounded-2xl border border-white/5 transition-all"> | |
| <div class="flex items-center gap-2"> | |
| <div class="h-8 w-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary border border-primary/20"> | |
| <i class="fas fa-tasks"></i> | |
| </div> | |
| <div> | |
| <span class="text-xs font-black uppercase tracking-widest text-white block">Thao tác hàng loạt</span> | |
| <span class="text-[10px] text-gray-400 block mt-0.5" id="selected-count">Đã chọn 0 phim</span> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-2 w-full sm:w-auto"> | |
| <button id="bulk-require-login-btn" class="flex-1 sm:flex-initial py-2.5 px-4 rounded-xl bg-red-500/20 border border-red-500/30 text-red-400 hover:bg-red-500/30 hover:scale-102 active:scale-98 transition-all text-xs font-black uppercase tracking-widest cursor-pointer flex items-center justify-center gap-1.5 font-bold"> | |
| <i class="fas fa-lock"></i> Khóa Login | |
| </button> | |
| <button id="bulk-free-login-btn" class="flex-1 sm:flex-initial py-2.5 px-4 rounded-xl bg-emerald-500/20 border border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/30 hover:scale-102 active:scale-98 transition-all text-xs font-black uppercase tracking-widest cursor-pointer flex items-center justify-center gap-1.5 font-bold"> | |
| <i class="fas fa-unlock"></i> Mở Khóa Free | |
| </button> | |
| </div> | |
| </div> | |
| <div class="overflow-y-auto max-h-[300px] border border-white/5 rounded-2xl scrollbar-thin bg-[#0a0b0f]"> | |
| <table class="w-full text-left border-collapse text-xs select-text"> | |
| <thead> | |
| <tr class="border-b border-white/5 bg-white/[0.02]"> | |
| <th class="py-3 px-4 font-black uppercase tracking-widest text-gray-500 text-[9px] w-12 text-center select-none"> | |
| <input type="checkbox" id="select-all-crawl" class="rounded border-white/10 bg-[#12141d] text-primary focus:ring-0 cursor-pointer" disabled /> | |
| </th> | |
| <th class="py-3 px-4 font-black uppercase tracking-widest text-gray-500 text-[9px] w-12 text-center">STT</th> | |
| <th class="py-3 px-4 font-black uppercase tracking-widest text-gray-500 text-[9px]">Tên phim</th> | |
| <th class="py-3 px-4 font-black uppercase tracking-widest text-gray-500 text-[9px] w-24">Kết quả</th> | |
| <th class="py-3 px-4 font-black uppercase tracking-widest text-gray-500 text-[9px]">Chi tiết</th> | |
| </tr> | |
| </thead> | |
| <tbody id="progress-table-body" class="divide-y divide-white/[0.03]"> | |
| <tr> | |
| <td colspan="5" class="py-8 text-center text-gray-600 font-medium">Chưa bắt đầu tiến trình cào dữ liệu</td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <!-- Logs Console --> | |
| <div class="glass rounded-3xl p-6 border border-white/5 space-y-4"> | |
| <div class="flex items-center justify-between border-b border-white/5 pb-4"> | |
| <span class="text-xs font-black uppercase tracking-widest text-gray-400"><i class="fas fa-terminal mr-2"></i> Nhật ký hoạt động chi tiết</span> | |
| <button id="clear-logs-btn" class="text-[10px] font-black uppercase tracking-widest text-gray-500 hover:text-white transition-colors">Xóa nhật ký</button> | |
| </div> | |
| <!-- Hộp logs giả lập terminal --> | |
| <div | |
| id="logs-console" | |
| class="h-72 w-full bg-[#0a0b0f] rounded-2xl p-5 font-mono text-xs overflow-y-auto space-y-2 border border-white/5 select-text scrollbar-thin" | |
| > | |
| <div class="text-gray-500">// Hệ thống đã sẵn sàng. Hãy cấu hình và nhấn Bắt đầu thu thập...</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </AdminLayout> | |
| <script> | |
| function init() { | |
| const { txatoast, txamodal } = (window as any); | |
| if (!txatoast) return; | |
| // Elements | |
| const tabUrlBtn = document.getElementById('tab-url-btn'); | |
| const tabBatchBtn = document.getElementById('tab-batch-btn'); | |
| const tabAllBtn = document.getElementById('tab-all-btn'); | |
| const sectionUrl = document.getElementById('section-url'); | |
| const sectionBatch = document.getElementById('section-batch'); | |
| const sectionAll = document.getElementById('section-all'); | |
| const textareaUrls = document.getElementById('crawl-urls') as HTMLTextAreaElement; | |
| const detectValid = document.getElementById('detect-valid'); | |
| const detectInvalid = document.getElementById('detect-invalid'); | |
| const startUrlCrawlBtn = document.getElementById('start-url-crawl-btn') as HTMLButtonElement; | |
| const startBatchCrawlBtn = document.getElementById('start-batch-crawl-btn') as HTMLButtonElement; | |
| const startAllCrawlBtn = document.getElementById('start-all-crawl-btn') as HTMLButtonElement; | |
| const startCountryCrawlBtn = document.getElementById('start-country-crawl-btn') as HTMLButtonElement; | |
| const startGenreCrawlBtn = document.getElementById('start-genre-crawl-btn') as HTMLButtonElement; | |
| const pageStartInput = document.getElementById('batch-page-start') as HTMLInputElement; | |
| const pageEndInput = document.getElementById('batch-page-end') as HTMLInputElement; | |
| const countrySelector = document.getElementById('country-selector') as HTMLSelectElement; | |
| const countryPageStartInput = document.getElementById('country-page-start') as HTMLInputElement; | |
| const countryPageEndInput = document.getElementById('country-page-end') as HTMLInputElement; | |
| const genreSelector = document.getElementById('genre-selector') as HTMLSelectElement; | |
| const genrePageStartInput = document.getElementById('genre-page-start') as HTMLInputElement; | |
| const genrePageEndInput = document.getElementById('genre-page-end') as HTMLInputElement; | |
| const crawlStatusBadge = document.getElementById('crawl-status-badge'); | |
| const currentMovieName = document.getElementById('current-movie-name'); | |
| const progressPercent = document.getElementById('progress-percent'); | |
| const progressBarInner = document.getElementById('progress-bar-inner'); | |
| const confirmPanel = document.getElementById('confirm-panel'); | |
| const confirmPanelSubtitle = document.getElementById('confirm-panel-subtitle'); | |
| const cancelCrawlBtn = document.getElementById('cancel-crawl-btn'); | |
| const confirmCrawlBtn = document.getElementById('confirm-crawl-btn'); | |
| const statsSuccess = document.getElementById('stats-success'); | |
| const statsSkipped = document.getElementById('stats-skipped'); | |
| const statsFailed = document.getElementById('stats-failed'); | |
| const progressTableBody = document.getElementById('progress-table-body'); | |
| const tableCount = document.getElementById('table-count'); | |
| const logsConsole = document.getElementById('logs-console'); | |
| const clearLogsBtn = document.getElementById('clear-logs-btn'); | |
| // Bulk action elements | |
| const bulkActionBar = document.getElementById('bulk-action-bar'); | |
| const selectedCount = document.getElementById('selected-count'); | |
| const bulkRequireLoginBtn = document.getElementById('bulk-require-login-btn'); | |
| const bulkFreeLoginBtn = document.getElementById('bulk-free-login-btn'); | |
| const selectAllChk = document.getElementById('select-all-crawl') as HTMLInputElement; | |
| let currentScrawledSlugs: any[] = []; | |
| const activeCrawlControls = document.getElementById('active-crawl-controls'); | |
| // Refresh bulk actions bar and select all status | |
| const refreshBulkActionBar = () => { | |
| const chks = document.querySelectorAll('.movie-select-chk:not([disabled])'); | |
| if (chks.length > 0) { | |
| if (bulkActionBar) bulkActionBar.classList.remove('hidden'); | |
| if (selectAllChk) selectAllChk.removeAttribute('disabled'); | |
| } else { | |
| if (bulkActionBar) bulkActionBar.classList.add('hidden'); | |
| if (selectAllChk) { | |
| selectAllChk.setAttribute('disabled', 'true'); | |
| selectAllChk.checked = false; | |
| } | |
| } | |
| updateSelectedCount(); | |
| }; | |
| // Update selected count display | |
| const updateSelectedCount = () => { | |
| const checkedChks = document.querySelectorAll('.movie-select-chk:checked'); | |
| if (selectedCount) { | |
| selectedCount.innerText = `Đã chọn ${checkedChks.length} phim`; | |
| } | |
| }; | |
| if (progressTableBody) { | |
| progressTableBody.addEventListener('change', (e) => { | |
| const target = e.target as HTMLInputElement; | |
| if (target && target.classList.contains('movie-select-chk')) { | |
| updateSelectedCount(); | |
| if (selectAllChk) { | |
| const enabledChks = document.querySelectorAll('.movie-select-chk:not([disabled])') as NodeListOf<HTMLInputElement>; | |
| const checkedChks = document.querySelectorAll('.movie-select-chk:checked') as NodeListOf<HTMLInputElement>; | |
| selectAllChk.checked = enabledChks.length > 0 && enabledChks.length === checkedChks.length; | |
| } | |
| } | |
| }); | |
| } | |
| if (selectAllChk) { | |
| selectAllChk.onchange = () => { | |
| const enabledChks = document.querySelectorAll('.movie-select-chk:not([disabled])') as NodeListOf<HTMLInputElement>; | |
| enabledChks.forEach(chk => { | |
| chk.checked = selectAllChk.checked; | |
| }); | |
| updateSelectedCount(); | |
| }; | |
| } | |
| const executeBulkLoginAction = async (action: 'require-login' | 'free-login') => { | |
| const checkedChks = document.querySelectorAll('.movie-select-chk:checked') as NodeListOf<HTMLInputElement>; | |
| const slugs = Array.from(checkedChks).map(chk => chk.getAttribute('data-slug')).filter(Boolean); | |
| if (slugs.length === 0) { | |
| txatoast.error('Vui lòng chọn ít nhất một phim để thực hiện thao tác!'); | |
| return; | |
| } | |
| const actionText = action === 'require-login' ? 'Yêu cầu đăng nhập' : 'Không yêu cầu đăng nhập'; | |
| const actionType = action === 'require-login' ? 'warning' : 'success'; | |
| txamodal.show({ | |
| title: 'Xác nhận thay đổi quyền xem phim', | |
| message: `Bạn có chắc chắn muốn thiết lập <strong>${actionText} cho {slugs.length} phim đã chọn?`, | |
| type: actionType, | |
| confirmText: 'Xác nhận', | |
| cancelText: 'Hủy', | |
| onConfirm: async () => { | |
| try { | |
| log(`Đang thiết lập ${actionText} hàng loạt cho ${slugs.length} phim...`, 'info'); | |
| const res = await fetch('/api/admin/movie/bulk', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ slugs, action }) | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.error || 'Lỗi không xác định'); | |
| txatoast.success(data.message || 'Cập nhật thành công!'); | |
| log(data.message || 'Thiết lập hàng loạt hoàn tất.', 'success'); | |
| slugs.forEach(slug => { | |
| const row = progressTableBody?.querySelector(`[data-slug="${slug}"]`); | |
| if (row) { | |
| const detailEl = row.querySelector('.status-detail'); | |
| if (detailEl) { | |
| const oldBadge = detailEl.querySelector('.login-badge'); | |
| if (oldBadge) oldBadge.remove(); | |
| const loginBadge = document.createElement('span'); | |
| loginBadge.className = 'login-badge inline-flex items-center px-1.5 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider ml-1.5 ' + | |
| (action === 'require-login' | |
| ? 'bg-red-500/10 border border-red-500/20 text-red-400' | |
| : 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-400'); | |
| loginBadge.innerHTML = action === 'require-login' | |
| ? '<i class="fas fa-lock mr-1"></i> Khóa Login' | |
| : '<i class="fas fa-unlock mr-1"></i> Free'; | |
| detailEl.appendChild(loginBadge); | |
| } | |
| const chk = row.querySelector('.movie-select-chk'); | |
| if (chk) chk.checked = false; | |
| } | |
| }); | |
| if (selectAllChk) selectAllChk.checked = false; | |
| updateSelectedCount(); | |
| } catch (e) { | |
| txatoast.error(e.message || 'Lỗi khi cập nhật quyền xem phim'); | |
| log(`Lỗi khi cập nhật: ${e.message}`, 'error'); | |
| } | |
| return true; | |
| } | |
| }); | |
| }; | |
| if (bulkRequireLoginBtn) { | |
| bulkRequireLoginBtn.onclick = () => executeBulkLoginAction('require-login'); | |
| } | |
| if (bulkFreeLoginBtn) { | |
| bulkFreeLoginBtn.onclick = () => executeBulkLoginAction('free-login'); | |
| } | |
| const pauseResumeBtn = document.getElementById('pause-resume-btn'); | |
| const stopCrawlBtn = document.getElementById('stop-crawl-btn'); | |
| let crawlQueue: any[] = []; | |
| let crawlIndex = 0; | |
| let isCrawling = false; | |
| let isPaused = false; | |
| let successCount = 0; | |
| let activeWorkers = 0; | |
| const CONCURRENCY = 2; | |
| let skipCount = 0; | |
| let failCount = 0; | |
| // Helper to control buttons and inputs state visually | |
| const setButtonsState = (isLoading, activeBtn = null) => { | |
| const btns = [startUrlCrawlBtn, startBatchCrawlBtn, startAllCrawlBtn, startCountryCrawlBtn, startGenreCrawlBtn]; | |
| const inputs = [textareaUrls, pageStartInput, pageEndInput, countrySelector, countryPageStartInput, countryPageEndInput, genreSelector, genrePageStartInput, genrePageEndInput]; | |
| btns.forEach(btn => { | |
| if (!btn) return; | |
| if (isLoading) { | |
| btn.disabled = true; | |
| btn.classList.add('opacity-50', 'cursor-not-allowed'); | |
| btn.classList.remove('hover:scale-102', 'active:scale-98'); | |
| if (btn === activeBtn) { | |
| btn.setAttribute('data-orig-html', btn.innerHTML); | |
| btn.innerHTML = ` Đang quét...`; | |
| } | |
| } else { | |
| btn.disabled = false; | |
| btn.classList.remove('opacity-50', 'cursor-not-allowed'); | |
| btn.classList.add('hover:scale-102', 'active:scale-98'); | |
| const origHtml = btn.getAttribute('data-orig-html'); | |
| if (origHtml) { | |
| btn.innerHTML = origHtml; | |
| btn.removeAttribute('data-orig-html'); | |
| } | |
| } | |
| }); | |
| inputs.forEach(input => { | |
| if (!input) return; | |
| if (isLoading) { | |
| input.setAttribute('disabled', 'true'); | |
| input.classList.add('opacity-50', 'cursor-not-allowed'); | |
| } else { | |
| input.removeAttribute('disabled'); | |
| input.classList.remove('opacity-50', 'cursor-not-allowed'); | |
| } | |
| }); | |
| }; | |
| // 1. Tab switching for 3 tabs | |
| if (tabUrlBtn && tabBatchBtn && tabAllBtn && sectionUrl && sectionBatch && sectionAll) { | |
| const activeClass = 'pb-4 text-[10px] sm:text-xs font-black uppercase tracking-widest text-primary border-b-2 border-primary transition-all whitespace-nowrap cursor-pointer'; | |
| const inactiveClass = 'pb-4 text-[10px] sm:text-xs font-black uppercase tracking-widest text-gray-500 border-b-2 border-transparent hover:text-white transition-all whitespace-nowrap cursor-pointer'; | |
| tabUrlBtn.onclick = () => { | |
| tabUrlBtn.className = activeClass; | |
| tabBatchBtn.className = inactiveClass; | |
| tabAllBtn.className = inactiveClass; | |
| sectionUrl.classList.remove('hidden'); | |
| sectionBatch.classList.add('hidden'); | |
| sectionAll.classList.add('hidden'); | |
| }; | |
| tabBatchBtn.onclick = () => { | |
| tabUrlBtn.className = inactiveClass; | |
| tabBatchBtn.className = activeClass; | |
| tabAllBtn.className = inactiveClass; | |
| sectionUrl.classList.add('hidden'); | |
| sectionBatch.classList.remove('hidden'); | |
| sectionAll.classList.add('hidden'); | |
| }; | |
| tabAllBtn.onclick = () => { | |
| tabUrlBtn.className = inactiveClass; | |
| tabBatchBtn.className = inactiveClass; | |
| tabAllBtn.className = activeClass; | |
| sectionUrl.classList.add('hidden'); | |
| sectionBatch.classList.add('hidden'); | |
| sectionAll.classList.remove('hidden'); | |
| }; | |
| } | |
| // 2. Real-time URL Detection Logic | |
| const extractSlug = (input) => { | |
| if (!input) return null; | |
| const trimmed = input.trim(); | |
| if (!trimmed) return null; | |
| try { | |
| if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { | |
| const url = new URL(trimmed); | |
| const pathname = url.pathname; | |
| const parts = pathname.split('/').filter(Boolean); | |
| if (parts.length > 0) { | |
| return parts[parts.length - 1]; | |
| } | |
| } | |
| } catch (e) {} | |
| const parts = trimmed.split('/').filter(Boolean); | |
| if (parts.length > 0) { | |
| return parts[parts.length - 1]; | |
| } | |
| return trimmed; | |
| }; | |
| const detectUrls = () => { | |
| const text = textareaUrls.value; | |
| const lines = text.split('\n').map(l => l.trim()).filter(Boolean); | |
| let valid = 0; | |
| let invalid = 0; | |
| lines.forEach(line => { | |
| const slug = extractSlug(line); | |
| if (slug && slug.length > 2) { | |
| valid++; | |
| } else { | |
| invalid++; | |
| } | |
| }); | |
| if (detectValid) detectValid.innerText = `${valid} Hợp lệ`; | |
| if (detectInvalid) detectInvalid.innerText = `${invalid} Không hợp lệ`; | |
| }; | |
| if (textareaUrls) { | |
| textareaUrls.addEventListener('input', detectUrls); | |
| } | |
| // 3. Clear logs | |
| if (clearLogsBtn && logsConsole) { | |
| clearLogsBtn.onclick = () => { | |
| logsConsole.innerHTML = '<div class="text-gray-500">// Nhật ký đã được dọn sạch.</div>'; | |
| }; | |
| } | |
| // 4. Log helper | |
| const log = (message, type = 'info') => { | |
| if (!logsConsole) return; | |
| const timeStr = new Date().toLocaleTimeString(); | |
| let colorClass = 'text-gray-400'; | |
| let icon = '⚙️'; | |
| if (type === 'success') { | |
| colorClass = 'text-emerald-400 font-bold'; | |
| icon = '✓'; | |
| } else if (type === 'skipped') { | |
| colorClass = 'text-amber-400 font-bold'; | |
| icon = '➜'; | |
| } else if (type === 'warning') { | |
| colorClass = 'text-yellow-400'; | |
| icon = '⚠'; | |
| } else if (type === 'error') { | |
| colorClass = 'text-red-400 font-bold'; | |
| icon = '✗'; | |
| } | |
| const logRow = document.createElement('div'); | |
| logRow.className = `flex gap-2 leading-relaxed ${colorClass}`; | |
| logRow.innerHTML = `{icon} {message}`; | |
| logsConsole.appendChild(logRow); | |
| logsConsole.scrollTop = logsConsole.scrollHeight; | |
| }; | |
| // 5. Crawl core runner via SSE Response Streaming | |
| let activeStreamReader: ReadableStreamDefaultReader<Uint8Array> | null = null; | |
| const runCrawl = async (body, triggerBtn = null) => { | |
| // Disable buttons | |
| setButtonsState(true, triggerBtn); | |
| // Reset UI states | |
| if (statsSuccess) statsSuccess.innerText = '0'; | |
| if (statsSkipped) statsSkipped.innerText = '0'; | |
| if (statsFailed) statsFailed.innerText = '0'; | |
| if (progressPercent) progressPercent.innerText = '0%'; | |
| if (progressBarInner) progressBarInner.style.width = '0%'; | |
| if (confirmPanel) confirmPanel.classList.add('hidden'); | |
| if (bulkActionBar) bulkActionBar.classList.add('hidden'); | |
| if (selectAllChk) { | |
| selectAllChk.setAttribute('disabled', 'true'); | |
| selectAllChk.checked = false; | |
| } | |
| if (progressTableBody && body.scanOnly) { | |
| progressTableBody.innerHTML = ` | |
| <tr> | |
| <td colspan="5" class="py-8 text-center text-gray-500 font-medium">Đang quét tìm danh sách phim từ API...</td> | |
| </tr> | |
| `; | |
| } | |
| if (tableCount) tableCount.innerText = 'Đang quét...'; | |
| if (crawlStatusBadge) { | |
| const isScan = body.scanOnly; | |
| crawlStatusBadge.innerText = isScan ? 'Đang quét' : 'Đang chạy'; | |
| crawlStatusBadge.className = isScan | |
| ? 'text-[10px] font-black uppercase tracking-widest bg-yellow-500/20 border border-yellow-500/40 px-3 py-1 rounded-xl text-yellow-400 animate-pulse' | |
| : 'text-[10px] font-black uppercase tracking-widest bg-primary/20 border border-primary/40 px-3 py-1 rounded-xl text-primary animate-pulse'; | |
| } | |
| log(body.scanOnly ? 'Bắt đầu kết nối & quét danh sách phim...' : 'Khởi chạy tiến trình tải dữ liệu chi tiết...', 'info'); | |
| try { | |
| const response = await fetch('/api/admin/crawl', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(body) | |
| }); | |
| if (!response.ok) { | |
| if (response.status === 401) { | |
| throw new Error('Bạn không có quyền quản trị! Hãy đăng nhập lại.'); | |
| } | |
| throw new Error(`Gặp lỗi kết nối (HTTP ${response.status})`); | |
| } | |
| const reader = response.body?.getReader(); | |
| if (!reader) throw new Error('Không thể khởi tạo luồng dữ liệu thời gian thực'); | |
| activeStreamReader = reader; | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| while (true) { | |
| const { value, done } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const parts = buffer.split('\n\n'); | |
| buffer = parts.pop() || ''; | |
| for (const part of parts) { | |
| const cleanPart = part.replace(/^data:\s*/, '').trim(); | |
| if (!cleanPart) continue; | |
| try { | |
| const data = JSON.parse(cleanPart); | |
| handleProgressMessage(data); | |
| } catch (e) { | |
| console.error('Error parsing SSE row:', cleanPart, e); | |
| } | |
| } | |
| } | |
| if (!body.scanOnly) { | |
| log('--- ĐÃ HOÀN TẤT TIẾN TRÌNH CRAWL ---', 'success'); | |
| if (crawlStatusBadge) { | |
| crawlStatusBadge.innerText = 'Hoàn thành'; | |
| crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-emerald-500/20 border border-emerald-500/40 px-3 py-1 rounded-xl text-emerald-400'; | |
| } | |
| if (currentMovieName) currentMovieName.innerText = 'Tiến trình hoàn tất!'; | |
| } | |
| } catch (err) { | |
| log(`Lỗi nghiêm trọng: ${err.message}`, 'error'); | |
| txatoast.error(err.message); | |
| if (crawlStatusBadge) { | |
| crawlStatusBadge.innerText = 'Thất bại'; | |
| crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-red-500/20 border border-red-500/40 px-3 py-1 rounded-xl text-red-400'; | |
| } | |
| } finally { | |
| activeStreamReader = null; | |
| setButtonsState(false); | |
| } | |
| }; | |
| const handleProgressMessage = (data) => { | |
| if (data.action === 'detect') { | |
| log(`Phân tích xong: Đã phát hiện ${data.count} phim hợp lệ cần thu thập.`, 'info'); | |
| if (data.invalid && data.invalid.length > 0) { | |
| data.invalid.forEach((inv) => { | |
| log(`Bỏ qua URL lỗi: "${inv.raw}" (${inv.message})`, 'warning'); | |
| }); | |
| } | |
| if (tableCount) { | |
| tableCount.textContent = `Tổng cộng: ${data.count} phim`; | |
| } | |
| } else if (data.action === 'scan_found') { | |
| if (progressTableBody) { | |
| if (progressTableBody.querySelector('td[colspan]')) { | |
| progressTableBody.innerHTML = ''; | |
| } | |
| const existingRow = progressTableBody.querySelector(`[data-slug="${data.slug}"]`); | |
| if (!existingRow) { | |
| // Keep DOM small during active scan to prevent UI freeze | |
| if (progressTableBody.children.length >= 200) { | |
| const currentTotal = parseInt(tableCount?.textContent?.match(/\d+/)?.[0] || '200'); | |
| if (tableCount) { | |
| tableCount.textContent = `Tổng cộng: ${currentTotal + 1} phim`; | |
| } | |
| return; | |
| } | |
| const tr = document.createElement('tr'); | |
| tr.setAttribute('data-slug', data.slug); | |
| tr.className = 'hover:bg-white/[0.01] transition-colors border-b border-white/[0.03]'; | |
| let detailHtml = 'Đã tìm thấy trên danh sách'; | |
| let badgeClass = 'bg-gray-500/10 border border-gray-500/20 text-gray-400'; | |
| let badgeText = 'Chờ cào'; | |
| if (data.totalEpisodes !== undefined) { | |
| if (data.exists) { | |
| badgeClass = 'bg-blue-500/10 border border-blue-500/20 text-blue-400'; | |
| badgeText = 'Đã có'; | |
| detailHtml = `Có <strong>${data.totalEpisodes} tập trên KKPhim. Dự kiến cập nhật thêm: <strong class="text-emerald-400">+${data.expectedNew} tập mới.`; | |
| } else { | |
| badgeClass = 'bg-purple-500/10 border border-purple-500/20 text-purple-400'; | |
| badgeText = 'Phim mới'; | |
| detailHtml = `Phim mới hoàn toàn. Có {data.totalEpisodes} tập. Dự kiến thêm mới toàn bộ: <strong class="text-emerald-400">+${data.totalEpisodes} tập.`; | |
| } | |
| } | |
| const index = progressTableBody.children.length + 1; | |
| const disabledAttr = data.exists ? '' : 'disabled'; | |
| tr.innerHTML = ` | |
| <td class="py-3 px-4 text-center select-none w-12"> | |
| <input type="checkbox" data-slug="${data.slug}" class="movie-select-chk rounded border-white/10 bg-[#12141d] text-primary focus:ring-0 cursor-pointer" ${disabledAttr} | |
| <td class="py-3 px-4 text-center text-gray-500 font-mono font-bold">${index} | |
| <td class="py-3 px-4 font-semibold text-white"> | |
| <div class="font-bold text-[12px] whitespace-normal line-clamp-1">${data.name || 'Không rõ'} | |
| <div class="text-[10px] text-gray-500 font-mono mt-0.5">${data.slug} | |
| <td class="py-3 px-4"> | |
| <span class="status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider ${badgeClass}"> | |
| ${badgeText} | |
| <td class="status-detail py-3 px-4 text-gray-400 font-medium whitespace-normal text-[11px] leading-normal">${detailHtml} | |
| `; | |
| progressTableBody.appendChild(tr); | |
| if (data.exists) refreshBulkActionBar(); | |
| tr.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } | |
| } | |
| if (tableCount) { | |
| tableCount.textContent = `Tổng cộng: ${progressTableBody ? progressTableBody.children.length : 0} phim`; | |
| } | |
| } else if (data.action === 'scan_complete') { | |
| currentScrawledSlugs = data.slugs || []; | |
| log(`--- QUÉT DANH SÁCH HOÀN TẤT ---`, 'success'); | |
| log(`Đã phát hiện tổng cộng ${currentScrawledSlugs.length} phim hợp lệ.`, 'info'); | |
| if (currentScrawledSlugs.length === 0) { | |
| log('Không có phim nào để cào.', 'warning'); | |
| if (currentMovieName) currentMovieName.innerText = 'Không có phim mới cần cào.'; | |
| if (crawlStatusBadge) { | |
| crawlStatusBadge.innerText = 'Trống'; | |
| crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-gray-500/20 border border-gray-500/40 px-3 py-1 rounded-xl text-gray-400'; | |
| } | |
| setButtonsState(false); | |
| return; | |
| } | |
| log('Đã tải danh sách phim thành công. Vui lòng nhấn Bắt đầu cào chi tiết trong bảng xác nhận để tiếp tục...', 'info'); | |
| if (crawlStatusBadge) { | |
| crawlStatusBadge.innerText = 'Chờ xác nhận'; | |
| crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-amber-500/20 border border-amber-500/40 px-3 py-1 rounded-xl text-amber-400'; | |
| } | |
| if (currentMovieName) currentMovieName.innerText = `Đã quét xong. Sẵn sàng cào ${currentScrawledSlugs.length} phim...`; | |
| if (progressPercent) progressPercent.innerText = '0%'; | |
| if (progressBarInner) progressBarInner.style.width = '0%'; | |
| if (tableCount) tableCount.textContent = `Tổng cộng: ${currentScrawledSlugs.length} phim`; | |
| // Populate preview lists inside table (capped at 200) | |
| if (progressTableBody) { | |
| progressTableBody.innerHTML = ''; | |
| const previewList = currentScrawledSlugs.slice(0, 200); | |
| previewList.forEach((item, index) => { | |
| const tr = document.createElement('tr'); | |
| tr.setAttribute('data-slug', item.slug); | |
| tr.className = 'hover:bg-white/[0.01] transition-colors border-b border-white/[0.03]'; | |
| let detailHtml = 'Đã xếp hàng cào chi tiết'; | |
| let badgeClass = 'bg-gray-500/10 border border-gray-500/20 text-gray-400'; | |
| let badgeText = 'Chờ cào'; | |
| if (item.skip) { | |
| badgeClass = 'bg-amber-500/10 border border-amber-500/20 text-amber-400'; | |
| badgeText = 'Bỏ qua'; | |
| detailHtml = `<span class="text-amber-400 font-bold">Tự động bỏ qua (${item.skipReason}`; | |
| } else if (item.totalEpisodes !== undefined) { | |
| if (item.exists) { | |
| badgeClass = 'bg-blue-500/10 border border-blue-500/20 text-blue-400'; | |
| badgeText = 'Đã có'; | |
| detailHtml = `Có {item.totalEpisodes} tập trên KKPhim. Dự kiến cập nhật thêm: <strong class="text-emerald-400">+${item.expectedNew} tập mới.`; | |
| } else { | |
| badgeClass = 'bg-purple-500/10 border border-purple-500/20 text-purple-400'; | |
| badgeText = 'Phim mới'; | |
| detailHtml = `Phim mới hoàn toàn. Có {item.totalEpisodes} tập. Dự kiến thêm mới toàn bộ: <strong class="text-emerald-400">+${item.totalEpisodes} tập.`; | |
| } | |
| } | |
| const disabledAttr = (item.exists || item.skip) ? '' : 'disabled'; | |
| tr.innerHTML = ` | |
| <td class="py-3 px-4 text-center select-none w-12"> | |
| <input type="checkbox" data-slug="${item.slug}" class="movie-select-chk rounded border-white/10 bg-[#12141d] text-primary focus:ring-0 cursor-pointer" ${disabledAttr} | |
| <td class="py-3 px-4 text-center text-gray-500 font-mono font-bold">${index + 1} | |
| <td class="py-3 px-4 font-semibold text-white"> | |
| <div class="font-bold text-[12px] whitespace-normal line-clamp-1">${item.raw || 'Chưa rõ tên'} | |
| <div class="text-[10px] text-gray-500 font-mono mt-0.5">${item.slug} | |
| <td class="py-3 px-4"> | |
| <span class="status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider ${badgeClass}"> | |
| ${badgeText} | |
| <td class="status-detail py-3 px-4 text-gray-400 font-medium whitespace-normal text-[11px] leading-normal">${detailHtml} | |
| `; | |
| progressTableBody.appendChild(tr); | |
| }); | |
| if (currentScrawledSlugs.length > 200) { | |
| const tr = document.createElement('tr'); | |
| tr.id = 'more-movies-row'; | |
| tr.innerHTML = ` | |
| <td colspan="5" class="py-4 text-center text-gray-500 font-bold bg-white/[0.01]"> | |
| ... và ${currentScrawledSlugs.length - 200} | |
| `; | |
| progressTableBody.appendChild(tr); | |
| } | |
| refreshBulkActionBar(); | |
| } | |
| // Hiển thị bảng điều khiển xác nhận cào chi tiết để người dùng tự quyết định bấm bắt đầu | |
| if (confirmPanel) { | |
| confirmPanel.classList.remove('hidden'); | |
| } | |
| if (confirmPanelSubtitle) { | |
| confirmPanelSubtitle.innerHTML = `Sẵn sàng cào chi tiết {currentScrawledSlugs.length} phim đã phát hiện.`; | |
| } | |
| if (crawlStatusBadge) { | |
| crawlStatusBadge.innerText = 'Chờ xác nhận'; | |
| crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-amber-500/20 border border-amber-500/40 px-3 py-1 rounded-xl text-amber-400'; | |
| } | |
| if (currentMovieName) { | |
| currentMovieName.innerText = `Đã tìm thấy ${currentScrawledSlugs.length} phim. Vui lòng xác nhận cào chi tiết.`; | |
| } | |
| } else if (data.action === 'progress') { | |
| const percent = Math.round((data.index / data.total) * 100); | |
| if (progressPercent) progressPercent.innerText = `${percent}%`; | |
| if (progressBarInner) progressBarInner.style.width = `${percent}%`; | |
| if (currentMovieName) currentMovieName.innerText = `Đang crawl: ${data.name} (${data.slug})`; | |
| log(`[${data.index}/${data.total}] Bắt đầu tải dữ liệu: ${data.name}...`, 'info'); | |
| } else if (data.action === 'result') { | |
| let type: 'success' | 'skipped' | 'error' = 'success'; | |
| if (data.status === 'skipped') type = 'skipped'; | |
| else if (data.status === 'failed') type = 'error'; | |
| log(`${data.name}: ${data.message}`, type); | |
| // Update stats card | |
| if (data.status === 'success' || data.status === 'updated') { | |
| if (statsSuccess) statsSuccess.innerText = String(parseInt(statsSuccess.innerText) + 1); | |
| } else if (data.status === 'skipped') { | |
| if (statsSkipped) statsSkipped.innerText = String(parseInt(statsSkipped.innerText) + 1); | |
| } else if (data.status === 'failed') { | |
| if (statsFailed) statsFailed.innerText = String(parseInt(statsFailed.innerText) + 1); | |
| } | |
| // Add or update row to progress table | |
| if (progressTableBody) { | |
| let badgeClass = ''; | |
| let textStatus = ''; | |
| if (data.status === 'success') { | |
| badgeClass = 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-400'; | |
| textStatus = 'Mới'; | |
| } else if (data.status === 'updated') { | |
| badgeClass = 'bg-blue-500/10 border border-blue-500/20 text-blue-400'; | |
| textStatus = 'Tập Mới'; | |
| } else if (data.status === 'skipped') { | |
| badgeClass = 'bg-amber-500/10 border border-amber-500/20 text-amber-400'; | |
| textStatus = 'Bỏ qua'; | |
| } else if (data.status === 'failed') { | |
| badgeClass = 'bg-red-500/10 border border-red-500/20 text-red-400'; | |
| textStatus = 'Lỗi'; | |
| } | |
| const existingRow = progressTableBody.querySelector(`[data-slug="${data.slug}"]`); | |
| if (existingRow) { | |
| const badgeEl = existingRow.querySelector('.status-badge'); | |
| const detailEl = existingRow.querySelector('.status-detail'); | |
| const nameEl = existingRow.querySelector('.font-bold'); | |
| const chk = existingRow.querySelector('.movie-select-chk'); | |
| if (badgeEl) { | |
| badgeEl.className = `status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider ${badgeClass}`; | |
| badgeEl.textContent = textStatus; | |
| } | |
| if (detailEl) { | |
| detailEl.className = 'status-detail py-3 px-4 text-gray-400 font-medium whitespace-normal text-[11px] leading-normal'; | |
| detailEl.textContent = data.message || ''; | |
| } | |
| if (nameEl && data.name) { | |
| nameEl.textContent = data.name; | |
| } | |
| if (chk) { | |
| if (data.status === 'success' || data.status === 'updated' || data.status === 'skipped') { | |
| chk.removeAttribute('disabled'); | |
| } else { | |
| chk.setAttribute('disabled', 'true'); | |
| chk.checked = false; | |
| } | |
| } | |
| existingRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } else { | |
| // Append and prune to prevent massive DOM nodes | |
| const tr = document.createElement('tr'); | |
| tr.setAttribute('data-slug', data.slug); | |
| tr.className = 'hover:bg-white/[0.01] transition-colors border-b border-white/[0.03]'; | |
| const index = progressTableBody.children.length + 1; | |
| const disabledAttr = (data.status === 'success' || data.status === 'updated' || data.status === 'skipped') ? '' : 'disabled'; | |
| tr.innerHTML = ` | |
| <td class="py-3 px-4 text-center select-none w-12"> | |
| <input type="checkbox" data-slug="${data.slug || ''}" class="movie-select-chk rounded border-white/10 bg-[#12141d] text-primary focus:ring-0 cursor-pointer" ${disabledAttr} | |
| <td class="py-3 px-4 text-center text-gray-500 font-mono font-bold">${index} | |
| <td class="py-3 px-4 font-semibold text-white"> | |
| <div class="font-bold text-[12px] whitespace-normal line-clamp-1">${data.name || 'Không rõ'} | |
| <div class="text-[10px] text-gray-500 font-mono mt-0.5">${data.slug || ''} | |
| <td class="py-3 px-4"> | |
| <span class="status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider ${badgeClass}"> | |
| ${textStatus} | |
| <td class="status-detail py-3 px-4 text-gray-400 font-medium whitespace-normal text-[11px] leading-normal">${data.message || ''} | |
| `; | |
| const moreRow = document.getElementById('more-movies-row'); | |
| if (moreRow) { | |
| progressTableBody.insertBefore(tr, moreRow); | |
| } else { | |
| progressTableBody.appendChild(tr); | |
| } | |
| tr.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| if (progressTableBody.children.length > 300) { | |
| const firstChild = progressTableBody.firstChild; | |
| if (firstChild && firstChild !== moreRow) { | |
| progressTableBody.removeChild(firstChild); | |
| } | |
| } | |
| } | |
| refreshBulkActionBar(); | |
| } | |
| } else if (data.action === 'complete') { | |
| txamodal.show({ | |
| title: 'Thu thập hoàn tất!', | |
| message: `Quá trình crawl đã kết thúc tốt đẹp!<br/><br/> | |
| 🟢 Thành công/Cập nhật: {data.success} phim<br/> | |
| 🟡 Bỏ qua (không có tập mới): {data.skipped} phim<br/> | |
| 🔴 Thất bại: {data.failed} phim`, | |
| type: 'success', | |
| confirmText: 'Xem danh sách phim', | |
| cancelText: 'Đóng', | |
| onConfirm: () => { | |
| window.location.href = '/admin/phim'; | |
| return true; | |
| } | |
| }); | |
| } else if (data.status === 'info') { | |
| log(data.message, 'info'); | |
| } else if (data.status === 'warning') { | |
| log(data.message, 'warning'); | |
| } else if (data.action === 'error') { | |
| log(`Lỗi từ máy chủ: ${data.message}`, 'error'); | |
| } | |
| }; | |
| // === CRAWL QUEUE RUNNER & LOCAL STORAGE CONTROL === | |
| const saveCrawlState = () => { | |
| localStorage.setItem('tphimx_crawl_queue', JSON.stringify(crawlQueue)); | |
| localStorage.setItem('tphimx_crawl_index', String(crawlIndex)); | |
| localStorage.setItem('tphimx_crawl_active', String(isCrawling)); | |
| localStorage.setItem('tphimx_crawl_paused', String(isPaused)); | |
| localStorage.setItem('tphimx_crawl_stats', JSON.stringify({ | |
| success: successCount, | |
| skipped: skipCount, | |
| failed: failCount | |
| })); | |
| }; | |
| const clearCrawlState = () => { | |
| localStorage.removeItem('tphimx_crawl_queue'); | |
| localStorage.removeItem('tphimx_crawl_index'); | |
| localStorage.removeItem('tphimx_crawl_active'); | |
| localStorage.removeItem('tphimx_crawl_paused'); | |
| localStorage.removeItem('tphimx_crawl_stats'); | |
| isCrawling = false; | |
| isPaused = false; | |
| crawlQueue = []; | |
| crawlIndex = 0; | |
| if (activeCrawlControls) activeCrawlControls.classList.add('hidden'); | |
| }; | |
| const updateTableRowStatus = (slug, status, message, name) => { | |
| if (!progressTableBody) return; | |
| let row = progressTableBody.querySelector(`[data-slug="${slug}"]`); | |
| if (!row) { | |
| row = document.createElement('tr'); | |
| row.setAttribute('data-slug', slug); | |
| row.className = 'hover:bg-white/[0.01] transition-colors border-b border-white/[0.03]'; | |
| row.innerHTML = ` | |
| <td class="py-3 px-4 text-center select-none w-12"> | |
| <input type="checkbox" data-slug="${slug} | |
| <td class="py-3 px-4 font-semibold text-white"> | |
| <div class="font-bold text-[12px] whitespace-normal line-clamp-1 truncate-slug">${name || slug} | |
| <div class="text-[10px] text-gray-500 font-mono mt-0.5">${slug} | |
| <td class="py-3 px-4"> | |
| `; | |
| const moreRow = document.getElementById('more-movies-row'); | |
| if (moreRow) { | |
| progressTableBody.insertBefore(row, moreRow); | |
| } else { | |
| progressTableBody.appendChild(row); | |
| } | |
| } | |
| const badgeEl = row.querySelector('.status-badge'); | |
| const detailEl = row.querySelector('.status-detail'); | |
| const nameEl = row.querySelector('.font-bold'); | |
| const chk = row.querySelector('.movie-select-chk'); | |
| if (nameEl && name) { | |
| nameEl.textContent = name; | |
| } | |
| if (chk) { | |
| if (status === 'success' || status === 'skipped') { | |
| chk.removeAttribute('disabled'); | |
| } else { | |
| chk.setAttribute('disabled', 'true'); | |
| chk.checked = false; | |
| } | |
| } | |
| if (badgeEl) { | |
| if (status === 'processing') { | |
| badgeEl.className = 'status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider bg-primary/10 border border-primary/20 text-primary animate-pulse'; | |
| badgeEl.textContent = 'Đang cào'; | |
| } else if (status === 'success') { | |
| badgeEl.className = 'status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider bg-emerald-500/10 border border-emerald-500/20 text-emerald-400'; | |
| badgeEl.textContent = 'Mới/Cập nhật'; | |
| } else if (status === 'skipped') { | |
| badgeEl.className = 'status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider bg-amber-500/10 border border-amber-500/20 text-amber-400'; | |
| badgeEl.textContent = 'Bỏ qua'; | |
| } else if (status === 'failed') { | |
| badgeEl.className = 'status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider bg-red-500/10 border border-red-500/20 text-red-400'; | |
| badgeEl.textContent = 'Lỗi'; | |
| } | |
| } | |
| if (detailEl) { | |
| detailEl.className = status === 'processing' | |
| ? 'status-detail py-3 px-4 text-gray-500 font-medium whitespace-normal text-[11px] leading-normal' | |
| : 'status-detail py-3 px-4 text-gray-400 font-medium whitespace-normal text-[11px] leading-normal'; | |
| detailEl.textContent = message; | |
| } | |
| row.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| refreshBulkActionBar(); | |
| }; | |
| const runNextInQueue = async () => { | |
| if (!isCrawling) return; | |
| if (isPaused) { | |
| if (activeWorkers === 0) { | |
| if (crawlStatusBadge) { | |
| crawlStatusBadge.innerText = 'Tạm dừng'; | |
| crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-amber-500/20 border border-amber-500/40 px-3 py-1 rounded-xl text-amber-400'; | |
| } | |
| if (currentMovieName) currentMovieName.innerText = 'Đã tạm dừng tiến trình cào.'; | |
| log('Tiến trình cào phim đã được tạm dừng.', 'warning'); | |
| saveCrawlState(); | |
| } | |
| return; | |
| } | |
| if (crawlIndex >= crawlQueue.length) { | |
| if (activeWorkers === 0) { | |
| isCrawling = false; | |
| setButtonsState(false); | |
| if (activeCrawlControls) activeCrawlControls.classList.add('hidden'); | |
| if (crawlStatusBadge) { | |
| crawlStatusBadge.innerText = 'Hoàn thành'; | |
| crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-emerald-500/20 border border-emerald-500/40 px-3 py-1 rounded-xl text-emerald-400'; | |
| } | |
| if (currentMovieName) currentMovieName.innerText = 'Tiến trình hoàn tất!'; | |
| log('--- ĐÃ HOÀN TẤT TOÀN BỘ TIẾN TRÌNH CRAWL CHI TIẾT ---', 'success'); | |
| txamodal.show({ | |
| title: 'Thu thập hoàn tất!', | |
| message: `Quá trình crawl đã kết thúc tốt đẹp!<br/><br/> | |
| 🟢 Thành công/Cập nhật: {successCount} phim<br/> | |
| 🟡 Bỏ qua (không có tập mới): {skipCount} phim<br/> | |
| 🔴 Thất bại: {failCount} phim`, | |
| type: 'success', | |
| confirmText: 'Xem danh sách phim', | |
| cancelText: 'Đóng', | |
| onConfirm: () => { | |
| window.location.href = '/admin/phim'; | |
| return true; | |
| } | |
| }); | |
| clearCrawlState(); | |
| } | |
| return; | |
| } | |
| // Claim the next item safely and atomically | |
| const currentIndex = crawlIndex; | |
| crawlIndex++; | |
| saveCrawlState(); | |
| if (currentIndex >= crawlQueue.length) { | |
| return; | |
| } | |
| activeWorkers++; | |
| const item = crawlQueue[currentIndex]; | |
| if (item.skip) { | |
| skipCount++; | |
| if (statsSkipped) statsSkipped.innerText = String(skipCount); | |
| log(`${item.raw || item.slug}: Tự động bỏ qua (100% không có tập mới - ${item.skipReason})`, 'skipped'); | |
| updateTableRowStatus(item.slug, 'skipped', `Bỏ qua (100% không có tập mới - ${item.skipReason})`, item.raw); | |
| activeWorkers--; | |
| setTimeout(runNextInQueue, 50); | |
| return; | |
| } | |
| const percent = Math.round(((currentIndex + 1) / crawlQueue.length) * 100); | |
| if (progressPercent) progressPercent.innerText = `${percent}%`; | |
| if (progressBarInner) progressBarInner.style.width = `${percent}%`; | |
| const displayName = item.raw || item.slug; | |
| if (currentMovieName) currentMovieName.innerText = `[${currentIndex + 1}/${crawlQueue.length}] Đang crawl: ${displayName}`; | |
| log(`[${currentIndex + 1}/${crawlQueue.length}] Đang cào dữ liệu chi tiết cho: ${displayName}...`, 'info'); | |
| updateTableRowStatus(item.slug, 'processing', 'Đang kết nối & tải dữ liệu...', displayName); | |
| try { | |
| const response = await fetch('/api/admin/crawl', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ type: 'url', urls: [item.slug] }) | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Lỗi kết nối máy chủ (HTTP ${response.status})`); | |
| } | |
| const reader = response.body?.getReader(); | |
| if (!reader) throw new Error('Không thể khởi tạo luồng dữ liệu thời gian thực'); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| let isItemSuccess = false; | |
| let isItemSkipped = false; | |
| let itemMessage = ''; | |
| let realMovieTitle = displayName; | |
| while (true) { | |
| const { value, done } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const parts = buffer.split('\n\n'); | |
| buffer = parts.pop() || ''; | |
| for (const part of parts) { | |
| const cleanPart = part.replace(/^data:\s*/, '').trim(); | |
| if (!cleanPart) continue; | |
| try { | |
| const data = JSON.parse(cleanPart); | |
| if (data.action === 'result') { | |
| itemMessage = data.message; | |
| if (data.name) { | |
| realMovieTitle = data.name; | |
| } | |
| if (data.status === 'success' || data.status === 'updated') { | |
| isItemSuccess = true; | |
| } else if (data.status === 'skipped') { | |
| isItemSkipped = true; | |
| } else if (data.status === 'failed') { | |
| throw new Error(data.message || 'Lỗi không xác định từ API'); | |
| } | |
| } else if (data.action === 'error') { | |
| throw new Error(data.message); | |
| } | |
| } catch (e) { | |
| console.error('Lỗi phân tích cú pháp SSE:', e); | |
| } | |
| } | |
| } | |
| if (isItemSuccess) { | |
| successCount++; | |
| if (statsSuccess) statsSuccess.innerText = String(successCount); | |
| log(`${realMovieTitle}: ${itemMessage || 'Thành công'}`, 'success'); | |
| updateTableRowStatus(item.slug, 'success', itemMessage || 'Cập nhật thành công', realMovieTitle); | |
| } else if (isItemSkipped) { | |
| skipCount++; | |
| if (statsSkipped) statsSkipped.innerText = String(skipCount); | |
| log(`${realMovieTitle}: ${itemMessage || 'Bỏ qua (đã đủ tập)'}`, 'skipped'); | |
| updateTableRowStatus(item.slug, 'skipped', itemMessage || 'Đã đầy đủ tập, không có tập mới', realMovieTitle); | |
| } else { | |
| throw new Error(itemMessage || 'Không nhận được trạng thái thành công hoặc bỏ qua'); | |
| } | |
| } catch (e) { | |
| failCount++; | |
| if (statsFailed) statsFailed.innerText = String(failCount); | |
| log(`${displayName}: Thất bại (${e.message})`, 'error'); | |
| updateTableRowStatus(item.slug, 'failed', `Lỗi: ${e.message}`, displayName); | |
| } | |
| activeWorkers--; | |
| // Proceed after a short safety buffer to avoid rate limit bans | |
| setTimeout(runNextInQueue, 400); | |
| }; | |
| const startQueueCrawl = (slugsList, startIndex = 0, resumeStats = null) => { | |
| crawlQueue = slugsList; | |
| crawlIndex = startIndex; | |
| isCrawling = true; | |
| isPaused = false; | |
| // Lock buttons visually during queue execution | |
| setButtonsState(true); | |
| // Hiển thị panel điều khiển active | |
| if (activeCrawlControls) activeCrawlControls.classList.remove('hidden'); | |
| if (confirmPanel) confirmPanel.classList.add('hidden'); | |
| if (resumeStats) { | |
| successCount = resumeStats.success || 0; | |
| skipCount = resumeStats.skipped || 0; | |
| failCount = resumeStats.failed || 0; | |
| } else if (startIndex === 0) { | |
| successCount = 0; | |
| skipCount = 0; | |
| failCount = 0; | |
| } | |
| if (statsSuccess) statsSuccess.innerText = String(successCount); | |
| if (statsSkipped) statsSkipped.innerText = String(skipCount); | |
| if (statsFailed) statsFailed.innerText = String(failCount); | |
| if (crawlStatusBadge) { | |
| crawlStatusBadge.innerText = 'Đang cào'; | |
| crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-primary/20 border border-primary/40 px-3 py-1 rounded-xl text-primary animate-pulse'; | |
| } | |
| log(`Bắt đầu tiến trình cào chi tiết cho ${crawlQueue.length} phim (Từ vị trí #${crawlIndex + 1}) với ${CONCURRENCY} luồng song song...`, 'info'); | |
| saveCrawlState(); | |
| activeWorkers = 0; | |
| for (let i = 0; i < CONCURRENCY; i++) { | |
| setTimeout(runNextInQueue, i * 200); | |
| } | |
| }; | |
| const checkSavedCrawlState = () => { | |
| const savedQueueStr = localStorage.getItem('tphimx_crawl_queue'); | |
| const savedIndexStr = localStorage.getItem('tphimx_crawl_index'); | |
| const savedActiveStr = localStorage.getItem('tphimx_crawl_active'); | |
| const savedStatsStr = localStorage.getItem('tphimx_crawl_stats'); | |
| if (savedQueueStr && savedIndexStr && savedActiveStr === 'true') { | |
| try { | |
| const queue = JSON.parse(savedQueueStr); | |
| const index = parseInt(savedIndexStr); | |
| const stats = savedStatsStr ? JSON.parse(savedStatsStr) : { success: 0, skipped: 0, failed: 0 }; | |
| if (queue.length > 0 && index < queue.length) { | |
| setTimeout(() => { | |
| txamodal.show({ | |
| title: 'Phát hiện tiến trình bị gián đoạn', | |
| message: `Hệ thống phát hiện tiến trình cào phim trước đó của bạn đã bị gián đoạn do tải lại trang hoặc đóng tab.<br/><br/> | |
| 📊 Trạng thái hiện tại:<br/> | |
| - Đã xử lý: {index}/${queue.length} phim<br/> | |
| - Thành công: <strong class="text-emerald-400">${stats.success}<br/> | |
| - Bỏ qua: <strong class="text-amber-400">${stats.skipped}<br/> | |
| - Thất bại: <strong class="text-red-400">${stats.failed}<br/><br/> | |
| Bạn có muốn tiếp tục tiến trình cào phim này không?`, | |
| type: 'warning', | |
| confirmText: 'Tiếp tục cào', | |
| cancelText: 'Hủy bỏ', | |
| onConfirm: () => { | |
| // Populate table preview | |
| if (progressTableBody) { | |
| progressTableBody.innerHTML = ''; | |
| const previewList = queue.slice(0, 200); | |
| previewList.forEach((item, idx) => { | |
| const tr = document.createElement('tr'); | |
| tr.setAttribute('data-slug', item.slug); | |
| tr.className = 'hover:bg-white/[0.01] transition-colors border-b border-white/[0.03]'; | |
| let statusText = 'Chờ cào'; | |
| let statusBadge = 'bg-gray-500/10 border border-gray-500/20 text-gray-400'; | |
| let detailText = 'Đang trong hàng đợi cào'; | |
| if (idx < index) { | |
| statusText = 'Đã xong'; | |
| statusBadge = 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-400'; | |
| detailText = 'Đã hoàn thành trước khi reload trang'; | |
| } | |
| tr.innerHTML = ` | |
| <td class="py-3 px-4 text-center text-gray-500 font-mono font-bold">${idx + 1} | |
| <td class="py-3 px-4 font-semibold text-white"> | |
| <div class="font-bold text-[12px] whitespace-normal line-clamp-1">${item.raw || item.slug} | |
| <div class="text-[10px] text-gray-500 font-mono mt-0.5">${item.slug} | |
| <td class="py-3 px-4"> | |
| <span class="status-badge inline-flex items-center px-2 py-0.5 rounded-lg text-[9px] font-black uppercase tracking-wider ${statusBadge}"> | |
| ${statusText} | |
| <td class="status-detail py-3 px-4 text-gray-500 font-medium whitespace-normal text-[11px] leading-normal">${detailText} | |
| `; | |
| progressTableBody.appendChild(tr); | |
| }); | |
| if (queue.length > 200) { | |
| const tr = document.createElement('tr'); | |
| tr.id = 'more-movies-row'; | |
| tr.innerHTML = ` | |
| <td colspan="4" class="py-4 text-center text-gray-500 font-bold bg-white/[0.01]"> | |
| ... và ${queue.length - 200} | |
| `; | |
| progressTableBody.appendChild(tr); | |
| } | |
| } | |
| if (tableCount) tableCount.textContent = `Tổng cộng: ${queue.length} phim`; | |
| // Start crawl queue | |
| startQueueCrawl(queue, index, stats); | |
| return true; | |
| }, | |
| onCancel: () => { | |
| clearCrawlState(); | |
| return true; | |
| } | |
| }); | |
| }, 600); | |
| } else { | |
| clearCrawlState(); | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| clearCrawlState(); | |
| } | |
| } | |
| }; | |
| // Alert on navigation if crawling | |
| window.addEventListener('beforeunload', (e) => { | |
| if (isCrawling && !isPaused) { | |
| e.preventDefault(); | |
| e.returnValue = 'Tiến trình cào phim đang hoạt động. Rời khỏi trang lúc này có thể làm gián đoạn tiến trình!'; | |
| return e.returnValue; | |
| } | |
| }); | |
| // Pause/Resume and Cancel clicks | |
| if (pauseResumeBtn) { | |
| pauseResumeBtn.onclick = () => { | |
| if (!isCrawling) return; | |
| if (isPaused) { | |
| isPaused = false; | |
| pauseResumeBtn.innerHTML = '<i class="fas fa-pause"></i> Tạm dừng cào'; | |
| pauseResumeBtn.className = 'flex-1 py-3.5 px-4 rounded-xl bg-amber-500/20 border border-amber-500/30 text-amber-400 hover:bg-amber-500/30 hover:scale-102 active:scale-98 transition-all text-xs font-black uppercase tracking-widest cursor-pointer flex items-center justify-center gap-2 font-bold'; | |
| if (crawlStatusBadge) { | |
| crawlStatusBadge.innerText = 'Đang cào'; | |
| crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-primary/20 border border-primary/40 px-3 py-1 rounded-xl text-primary animate-pulse'; | |
| } | |
| log(`Tiếp tục tiến trình cào phim với ${CONCURRENCY} luồng song song...`, 'info'); | |
| saveCrawlState(); | |
| activeWorkers = 0; | |
| for (let i = 0; i < CONCURRENCY; i++) { | |
| setTimeout(runNextInQueue, i * 200); | |
| } | |
| } else { | |
| isPaused = true; | |
| pauseResumeBtn.innerHTML = '<i class="fas fa-play"></i> Tiếp tục cào'; | |
| pauseResumeBtn.className = 'flex-1 py-3.5 px-4 rounded-xl bg-emerald-500/20 border border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/30 hover:scale-102 active:scale-98 transition-all text-xs font-black uppercase tracking-widest cursor-pointer flex items-center justify-center gap-2 font-bold'; | |
| if (currentMovieName) currentMovieName.innerText = 'Đang tạm dừng cào...'; | |
| log('Đang thực hiện tạm dừng (tiến trình sẽ dừng sau khi phim hiện tại cào xong)...', 'warning'); | |
| saveCrawlState(); | |
| } | |
| }; | |
| } | |
| if (stopCrawlBtn) { | |
| stopCrawlBtn.onclick = () => { | |
| txamodal.show({ | |
| title: 'Xác nhận dừng tiến trình', | |
| message: 'Bạn có chắc chắn muốn hủy bỏ hoàn toàn tiến trình cào phim hiện tại không? Tất cả các phim chưa cào trong danh sách sẽ bị bỏ qua.', | |
| type: 'danger', | |
| confirmText: 'Dừng tiến trình', | |
| onConfirm: () => { | |
| isCrawling = false; | |
| isPaused = false; | |
| setButtonsState(false); | |
| if (activeCrawlControls) activeCrawlControls.classList.add('hidden'); | |
| if (crawlStatusBadge) { | |
| crawlStatusBadge.innerText = 'Đã hủy'; | |
| crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-gray-500/10 border border-gray-500/20 px-3 py-1 rounded-xl text-gray-400'; | |
| } | |
| if (currentMovieName) currentMovieName.innerText = 'Tiến trình đã bị dừng bỏ.'; | |
| log('Tiến trình cào đã bị dừng bỏ bởi quản trị viên.', 'warning'); | |
| clearCrawlState(); | |
| return true; | |
| } | |
| }); | |
| }; | |
| } | |
| // Cancel & Confirm Click Handlers | |
| if (cancelCrawlBtn) { | |
| cancelCrawlBtn.onclick = () => { | |
| if (confirmPanel) confirmPanel.classList.add('hidden'); | |
| setButtonsState(false); | |
| if (progressTableBody) { | |
| progressTableBody.innerHTML = ` | |
| `; | |
| } | |
| if (crawlStatusBadge) { | |
| crawlStatusBadge.innerText = 'Đã hủy'; | |
| crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-gray-500/10 border border-gray-500/20 px-3 py-1 rounded-xl text-gray-400'; | |
| } | |
| if (currentMovieName) { | |
| currentMovieName.innerText = 'Tiến trình quét đã bị hủy bỏ.'; | |
| } | |
| log('Tiến trình quét đã bị hủy bỏ bởi quản trị viên.', 'warning'); | |
| currentScrawledSlugs = []; | |
| }; | |
| } | |
| if (confirmCrawlBtn) { | |
| confirmCrawlBtn.onclick = () => { | |
| if (currentScrawledSlugs.length === 0) { | |
| txatoast.error('Không có phim nào trong danh sách quét để crawl!'); | |
| return; | |
| } | |
| if (confirmPanel) confirmPanel.classList.add('hidden'); | |
| // Bắt đầu cào hàng đợi phim từ đầu | |
| startQueueCrawl(currentScrawledSlugs, 0); | |
| }; | |
| } | |
| // Trigger URL Crawl | |
| if (startUrlCrawlBtn) { | |
| startUrlCrawlBtn.onclick = () => { | |
| if (activeStreamReader || isCrawling) { | |
| txatoast.warning('Tiến trình crawl đang chạy, vui lòng đợi hoàn tất!'); | |
| return; | |
| } | |
| const text = textareaUrls.value; | |
| const lines = text.split('\n').map(l => l.trim()).filter(Boolean); | |
| if (lines.length === 0) { | |
| txatoast.error('Vui lòng nhập ít nhất 1 URL hoặc slug phim!'); | |
| return; | |
| } | |
| // Reset UI states | |
| if (statsSuccess) statsSuccess.innerText = '0'; | |
| if (statsSkipped) statsSkipped.innerText = '0'; | |
| if (statsFailed) statsFailed.innerText = '0'; | |
| if (progressPercent) progressPercent.innerText = '0%'; | |
| if (progressBarInner) progressBarInner.style.width = '0%'; | |
| if (confirmPanel) confirmPanel.classList.add('hidden'); | |
| if (bulkActionBar) bulkActionBar.classList.add('hidden'); | |
| if (selectAllChk) { | |
| selectAllChk.setAttribute('disabled', 'true'); | |
| selectAllChk.checked = false; | |
| } | |
| log('Đang phân tích danh sách URL/Slug...', 'info'); | |
| currentScrawledSlugs = []; | |
| const invalidUrls = []; | |
| lines.forEach(line => { | |
| const slug = extractSlug(line); | |
| if (slug && slug.length > 2) { | |
| currentScrawledSlugs.push({ | |
| raw: slug, | |
| slug: slug, | |
| exists: false | |
| }); | |
| } else { | |
| invalidUrls.push(line); | |
| } | |
| }); | |
| if (invalidUrls.length > 0) { | |
| invalidUrls.forEach(url => { | |
| log(`Bỏ qua URL/Slug không hợp lệ: "${url}"`, 'warning'); | |
| }); | |
| } | |
| if (currentScrawledSlugs.length === 0) { | |
| txatoast.error('Không tìm thấy URL hoặc slug phim hợp lệ nào!'); | |
| return; | |
| } | |
| log(`--- QUÉT DANH SÁCH HOÀN TẤT ---`, 'success'); | |
| log(`Đã phát hiện tổng cộng ${currentScrawledSlugs.length} phim hợp lệ.`, 'info'); | |
| if (crawlStatusBadge) { | |
| crawlStatusBadge.innerText = 'Chờ xác nhận'; | |
| crawlStatusBadge.className = 'text-[10px] font-black uppercase tracking-widest bg-amber-500/20 border border-amber-500/40 px-3 py-1 rounded-xl text-amber-400'; | |
| } | |
| if (currentMovieName) currentMovieName.innerText = `Sẵn sàng cào ${currentScrawledSlugs.length} phim...`; | |
| if (tableCount) tableCount.textContent = `Tổng cộng: ${currentScrawledSlugs.length} phim`; | |
| // Populate preview lists inside table (capped at 200) | |
| if (progressTableBody) { | |
| progressTableBody.innerHTML = ''; | |
| const previewList = currentScrawledSlugs.slice(0, 200); | |
| previewList.forEach((item, index) => { | |
| const tr = document.createElement('tr'); | |
| tr.setAttribute('data-slug', item.slug); | |
| tr.className = 'hover:bg-white/[0.01] transition-colors border-b border-white/[0.03]'; | |
| tr.innerHTML = ` | |
| <td class="py-3 px-4 text-center select-none w-12"> | |
| <input type="checkbox" data-slug="${item.slug} | |
| <td class="py-3 px-4 text-center text-gray-500 font-mono font-bold">${index + 1} | |
| <td class="py-3 px-4 font-semibold text-white"> | |
| <div class="font-bold text-[12px] whitespace-normal line-clamp-1 truncate-slug">${item.slug} | |
| <div class="text-[10px] text-gray-500 font-mono mt-0.5">${item.slug} | |
| <td class="py-3 px-4"> | |
| `; | |
| progressTableBody.appendChild(tr); | |
| }); | |
| if (currentScrawledSlugs.length > 200) { | |
| const tr = document.createElement('tr'); | |
| tr.id = 'more-movies-row'; | |
| tr.innerHTML = ` | |
| <td colspan="5" class="py-4 text-center text-gray-500 font-bold bg-white/[0.01]"> | |
| ... và ${currentScrawledSlugs.length - 200} | |
| `; | |
| progressTableBody.appendChild(tr); | |
| } | |
| refreshBulkActionBar(); | |
| } | |
| // Hiển thị bảng điều khiển xác nhận cào chi tiết | |
| if (confirmPanel) { | |
| confirmPanel.classList.remove('hidden'); | |
| } | |
| if (confirmPanelSubtitle) { | |
| confirmPanelSubtitle.innerHTML = `Sẵn sàng cào chi tiết {currentScrawledSlugs.length} phim đã nhập.`; | |
| } | |
| }; | |
| } | |
| // Trigger Batch Crawl | |
| if (startBatchCrawlBtn) { | |
| startBatchCrawlBtn.onclick = () => { | |
| if (activeStreamReader || isCrawling) { | |
| txatoast.warning('Tiến trình crawl đang chạy, vui lòng đợi hoàn tất!'); | |
| return; | |
| } | |
| const start = parseInt(pageStartInput.value); | |
| const end = parseInt(pageEndInput.value); | |
| if (isNaN(start) || isNaN(end) || start < 1 || end < 1) { | |
| txatoast.error('Vui lòng nhập số trang hợp lệ!'); | |
| return; | |
| } | |
| if (start > end) { | |
| txatoast.error('Trang bắt đầu không thể lớn hơn trang kết thúc!'); | |
| return; | |
| } | |
| txamodal.show({ | |
| title: 'Xác nhận quét danh sách hàng loạt', | |
| message: `Bạn muốn quét tìm danh sách phim mới từ trang {start} đến trang {end} trước? Bạn có thể kiểm tra danh sách này trước khi quyết định tải chi tiết phim.`, | |
| type: 'warning', | |
| confirmText: 'Bắt đầu quét danh sách', | |
| onConfirm: () => { | |
| runCrawl({ type: 'batch', pageStart: start, pageEnd: end, scanOnly: true }, startBatchCrawlBtn); | |
| return true; | |
| } | |
| }); | |
| }; | |
| } | |
| // Trigger All Crawl | |
| if (startAllCrawlBtn) { | |
| startAllCrawlBtn.onclick = () => { | |
| if (activeStreamReader || isCrawling) { | |
| txatoast.warning('Tiến trình crawl đang chạy, vui lòng đợi hoàn tất!'); | |
| return; | |
| } | |
| txamodal.show({ | |
| title: 'Xác nhận quét danh sách TOÀN BỘ', | |
| message: 'Bạn muốn quét lấy toàn bộ danh sách phim (hơn 22.826 phim) trên API KKPhim trước? Hệ thống sẽ chỉ tải danh sách tên phim & URL để bạn phê duyệt trước khi tải chi tiết phim.', | |
| type: 'warning', | |
| confirmText: 'Bắt đầu quét danh sách', | |
| onConfirm: () => { | |
| runCrawl({ type: 'all', scanOnly: true }, startAllCrawlBtn); | |
| return true; | |
| } | |
| }); | |
| }; | |
| } | |
| // Trigger Country Crawl | |
| if (startCountryCrawlBtn && countrySelector) { | |
| startCountryCrawlBtn.onclick = () => { | |
| if (activeStreamReader || isCrawling) { | |
| txatoast.warning('Tiến trình crawl đang chạy, vui lòng đợi hoàn tất!'); | |
| return; | |
| } | |
| const countrySlug = countrySelector.value; | |
| const start = parseInt(countryPageStartInput.value); | |
| const end = parseInt(countryPageEndInput.value); | |
| if (isNaN(start) || isNaN(end) || start < 1 || end < 1) { | |
| txatoast.error('Vui lòng nhập số trang hợp lệ!'); | |
| return; | |
| } | |
| if (start > end) { | |
| txatoast.error('Trang bắt đầu không thể lớn hơn trang kết thúc!'); | |
| return; | |
| } | |
| const countryName = countrySelector.options[countrySelector.selectedIndex].text; | |
| txamodal.show({ | |
| title: 'Xác nhận quét theo quốc gia', | |
| message: `Bạn muốn quét tìm danh sách phim của quốc gia {countryName} từ trang {start} đến trang {end} trước? Bạn có thể kiểm tra danh sách này trước khi quyết định tải chi tiết phim.`, | |
| type: 'warning', | |
| confirmText: 'Bắt đầu quét danh sách', | |
| onConfirm: () => { | |
| runCrawl({ type: 'country', countrySlug, pageStart: start, pageEnd: end, scanOnly: true }, startCountryCrawlBtn); | |
| return true; | |
| } | |
| }); | |
| }; | |
| } | |
| // Trigger Genre Crawl | |
| if (startGenreCrawlBtn && genreSelector) { | |
| startGenreCrawlBtn.onclick = () => { | |
| if (activeStreamReader || isCrawling) { | |
| txatoast.warning('Tiến trình crawl đang chạy, vui lòng đợi hoàn tất!'); | |
| return; | |
| } | |
| const genreSlug = genreSelector.value; | |
| const start = parseInt(genrePageStartInput.value); | |
| const end = parseInt(genrePageEndInput.value); | |
| if (isNaN(start) || isNaN(end) || start < 1 || end < 1) { | |
| txatoast.error('Vui lòng nhập số trang hợp lệ!'); | |
| return; | |
| } | |
| if (start > end) { | |
| txatoast.error('Trang bắt đầu không thể lớn hơn trang kết thúc!'); | |
| return; | |
| } | |
| const genreName = genreSelector.options[genreSelector.selectedIndex].text; | |
| txamodal.show({ | |
| title: 'Xác nhận quét theo thể loại', | |
| message: `Bạn muốn quét tìm danh sách phim của thể loại {genreName} từ trang {start} đến trang {end} trước? Bạn có thể kiểm tra danh sách này trước khi quyết định tải chi tiết phim.`, | |
| type: 'warning', | |
| confirmText: 'Bắt đầu quét danh sách', | |
| onConfirm: () => { | |
| runCrawl({ type: 'genre', genreSlug, pageStart: start, pageEnd: end, scanOnly: true }, startGenreCrawlBtn); | |
| return true; | |
| } | |
| }); | |
| }; | |
| } | |
| // Khôi phục nếu có tiến trình cào dở dang | |
| checkSavedCrawlState(); | |
| } | |
| document.addEventListener('astro:page-load', init); | |
| </script> | |
| { | |
| background: rgba(18, 20, 29, 0.4); | |
| backdrop-filter: blur(40px) saturate(200%); | |
| } | |
| #logs-console::-webkit-scrollbar { | |
| width: 6px; | |
| } | |
| #logs-console::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| #logs-console::-webkit-scrollbar-thumb { | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 10px; | |
| } | |
| #logs-console::-webkit-scrollbar-thumb:hover { | |
| background: rgba(255, 255, 255, 0.2); | |
| } | |