dptxa-proxy / src /pages /api /admin /crawl.js
TXAVLOG
Deploy DPTXA to Hugging Face Spaces
4bea261
Raw
History Blame Contribute Delete
26.5 kB
import { getLocalMovies, saveLocalMovie, syncFromMovies } from '../../../lib/localDb.js';
// Cấu hình timeout cho fetch
async function fetchWithTimeout(resource, options = {}) {
const { timeout = 8000 } = options;
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(resource, {
...options,
signal: controller.signal
});
return response;
} finally {
clearTimeout(id);
}
}
// Trích xuất slug từ URL hoặc chuỗi nhập vào
function 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;
}
// Định dạng tên tập phim thành 2 chữ số (ví dụ: Tập 01, Tập 02, Tập 09 thay vì Tập 1, Tập 2)
function formatEpisodeName(name) {
if (!name) return name;
let str = name.trim();
// Check if name is pure number, e.g. "1", "2", "03"
if (/^\d+$/.test(str)) {
const num = parseInt(str, 10);
return `Tập ${num.toString().padStart(2, '0')}`;
}
// Check if name starts with "tập" followed by space and numbers, e.g., "Tập 1", "tap 2", "Tập 03"
const match = str.match(/^(tập|tap)\s+(\d+)$/i);
if (match) {
const num = parseInt(match[2], 10);
return `Tập ${num.toString().padStart(2, '0')}`;
}
return str;
}
// Hàm dọn dẹp và giới hạn máy chủ phát phim (Ví dụ: #Hà Nội (Thuyết Minh 1), #Hà Nội (Vietsub) -> Vietsub & Thuyết Minh)
// Chỉ giữ tối đa 2 server phát phim chính (ưu tiên Vietsub, Thuyết Minh, Lồng Tiếng) để tránh quá tải danh sách server
function cleanAndFilterServers(episodes) {
if (!Array.isArray(episodes)) return [];
const tempMap = new Map();
for (const srv of episodes) {
if (!srv || !srv.server_name) continue;
let rawName = srv.server_name.trim();
let cleanName = '';
if (/vietsub/i.test(rawName)) {
cleanName = 'Vietsub';
} else if (/thuyết minh|thuyet minh/i.test(rawName)) {
cleanName = 'Thuyết Minh';
} else if (/lồng tiếng|long tieng/i.test(rawName)) {
cleanName = 'Lồng Tiếng';
} else {
cleanName = rawName.replace(/#\s*/g, '').replace(/\([^)]*\)/g, '').trim();
if (!cleanName) cleanName = 'Vietsub';
}
// Chỉ giữ các máy chủ chuẩn
if (cleanName !== 'Vietsub' && cleanName !== 'Thuyết Minh' && cleanName !== 'Lồng Tiếng') {
continue;
}
// Lưu tập phim của máy chủ đầu tiên xuất hiện cho mỗi loại
if (!tempMap.has(cleanName)) {
srv.server_name = cleanName;
tempMap.set(cleanName, srv);
}
}
// Dựng lại danh sách máy chủ theo độ ưu tiên: 1. Vietsub, 2. Thuyết Minh
const result = [];
if (tempMap.has('Vietsub')) result.push(tempMap.get('Vietsub'));
if (tempMap.has('Thuyết Minh')) result.push(tempMap.get('Thuyết Minh'));
// Nếu vẫn chưa đủ 2 server và có Lồng Tiếng, thêm Lồng Tiếng vào
if (result.length < 2 && tempMap.has('Lồng Tiếng')) {
result.push(tempMap.get('Lồng Tiếng'));
}
// Trường hợp không phát hiện máy chủ chuẩn nào, giữ lại máy chủ gốc đầu tiên nhưng dọn tên
if (result.length === 0 && episodes.length > 0) {
const fallbackSrv = episodes[0];
fallbackSrv.server_name = fallbackSrv.server_name.replace(/#\s*/g, '').replace(/\([^)]*\)/g, '').trim() || 'Vietsub';
result.push(fallbackSrv);
}
return result;
}
// Hàm gộp tập phim tránh đè cấu hình Intro/Outro của Admin
function mergeEpisodes(existingServerList, crawledServerList) {
let addedCount = 0;
// Nhân bản sâu danh sách tập phim hiện tại để tránh tham chiếu và dễ gộp
const existingList = JSON.parse(JSON.stringify(existingServerList || []));
for (const crawledServer of crawledServerList) {
let existingServer = existingList.find(s => s.server_name === crawledServer.server_name);
if (!existingServer) {
crawledServer.server_data.forEach(ep => {
ep.name = formatEpisodeName(ep.name);
});
existingList.push(crawledServer);
addedCount += crawledServer.server_data.length;
continue;
}
// Đồng bộ lại toàn bộ định dạng tên tập hiện tại để so khớp chính xác
existingServer.server_data.forEach(ep => {
ep.name = formatEpisodeName(ep.name);
});
// Map nhanh theo tên tập và ghi nhớ vị trí index gốc
const existingMap = new Map(existingServer.server_data.map((e, idx) => [e.name, { ep: e, idx }]));
for (const crawledEp of crawledServer.server_data) {
crawledEp.name = formatEpisodeName(crawledEp.name);
const matched = existingMap.get(crawledEp.name);
if (matched) {
// Cập nhật link cào mới nhất, bảo tồn các mốc Intro/Outro admin đã cấu hình thủ công
existingServer.server_data[matched.idx] = {
...crawledEp,
introStart: matched.ep.introStart,
introEnd: matched.ep.introEnd,
outroStart: matched.ep.outroStart,
outroEnd: matched.ep.outroEnd
};
} else {
// Thêm tập mới vào server
existingServer.server_data.push(crawledEp);
addedCount++;
}
}
}
return { merged: existingList, addedCount };
}
export async function POST({ request }) {
// Check quyền admin từ cookie auth_token
const cookies = request.headers.get('cookie') || '';
const hasToken = cookies.includes('auth_token=');
if (!hasToken) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
}
const { type, urls, pageStart, pageEnd, scanOnly, countrySlug, genreSlug, selectedEpisodes } = await request.json();
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (data) => {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
} catch (e) {
// Ignore if the controller is already closed
}
};
try {
// Bơm mồi (padding) 2KB thông qua payload data hợp lệ để ép Vercel xả (flush) stream ngay lập tức
send({ action: 'connected', padding: 'x'.repeat(2048) });
const localMovies = await getLocalMovies();
let slugsToCrawl = [];
if (type === 'url') {
// Xử lý crawl theo danh sách URL
const urlList = Array.isArray(urls) ? urls : [urls];
const results = [];
for (const rawUrl of urlList) {
const slug = extractSlug(rawUrl);
if (slug) {
try {
// Fetch chi tiết từ API KKPhim để có thông tin chính xác về tên phim và số tập
const res = await fetchWithTimeout(`https://phimapi.com/phim/${slug}`);
if (!res.ok) {
throw new Error(`Phim không tồn tại trên KKPhim (HTTP ${res.status})`);
}
const data = await res.json();
if (!data.status || !data.movie) {
throw new Error('Dữ liệu API KKPhim không đúng định dạng');
}
const crawledMovie = data.movie;
const crawledEpisodes = cleanAndFilterServers(data.episodes || []);
// Tính số tập hiện có trên KKPhim
let crawledEpCount = 0;
if (crawledEpisodes.length > 0) {
crawledEpCount = Math.max(...crawledEpisodes.map(s => s.server_data?.length || 0));
}
const existing = localMovies[slug];
let expectedNew = crawledEpCount;
let exists = false;
if (existing) {
exists = true;
const { addedCount } = mergeEpisodes(existing.episodes, crawledEpisodes);
expectedNew = addedCount;
}
slugsToCrawl.push({
raw: crawledMovie.name,
slug,
totalEpisodes: crawledEpCount,
expectedNew,
exists
});
send({
action: 'scan_found',
name: crawledMovie.name,
slug: slug,
totalEpisodes: crawledEpCount,
expectedNew,
exists
});
} catch (e) {
// Fallback nếu fetch lỗi
slugsToCrawl.push({
raw: slug,
slug,
totalEpisodes: 0,
expectedNew: 0,
exists: false
});
send({
action: 'scan_found',
name: slug,
slug: slug,
totalEpisodes: 0,
expectedNew: 0,
exists: false
});
}
} else {
results.push({ raw: rawUrl, status: 'invalid', message: 'URL không hợp lệ' });
}
}
send({ action: 'detect', count: slugsToCrawl.length, invalid: results });
} else if (type === 'country') {
// Xử lý crawl theo quốc gia
const start = parseInt(pageStart || '1');
const end = parseInt(pageEnd || '1');
send({ status: 'info', message: `Đang quét danh sách phim quốc gia: ${countrySlug} từ trang ${start} đến ${end}...` });
for (let p = start; p <= end; p++) {
try {
const res = await fetchWithTimeout(`https://phimapi.com/v1/api/quoc-gia/${countrySlug}?page=${p}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const resData = await res.json();
const items = resData.data?.items || [];
for (const item of items) {
if (item.slug && !slugsToCrawl.some(s => s.slug === item.slug)) {
const existing = localMovies[item.slug];
let skip = false;
let skipReason = '';
let expectedNew = 1;
let exists = false;
if (existing) {
exists = true;
const localStatus = (existing.movie?.episode_current || '').toLowerCase();
const remoteStatus = (item.episode_current || '').toLowerCase();
const isStatusEqual = localStatus === remoteStatus && remoteStatus !== '';
const isBothCompleted = (localStatus.includes('hoàn tất') || localStatus.includes('full') || localStatus.includes('complete') || localStatus.includes('12/12')) &&
(remoteStatus.includes('hoàn tất') || remoteStatus.includes('full') || remoteStatus.includes('complete') || remoteStatus.includes('12/12'));
if (isStatusEqual || isBothCompleted) {
skip = true;
skipReason = remoteStatus.includes('hoàn tất') ? 'Phim đã hoàn tất' : 'Số tập trùng khớp';
expectedNew = 0;
}
}
slugsToCrawl.push({
raw: item.name,
slug: item.slug,
totalEpisodes: item.episode_total || (item.episode_current ? item.episode_current : '?'),
expectedNew,
exists,
skip,
skipReason
});
send({
action: 'scan_found',
name: item.name,
slug: item.slug,
totalEpisodes: item.episode_total || (item.episode_current ? item.episode_current : '?'),
expectedNew,
exists,
skip,
skipReason
});
}
}
} catch (e) {
send({ status: 'warning', message: `Không thể lấy danh sách trang ${p}: ${e.message}` });
}
}
send({ action: 'detect', count: slugsToCrawl.length, invalid: [] });
} else if (type === 'genre') {
// Xử lý crawl theo thể loại
const start = parseInt(pageStart || '1');
const end = parseInt(pageEnd || '1');
send({ status: 'info', message: `Đang quét danh sách phim thể loại: ${genreSlug} từ trang ${start} đến ${end}...` });
for (let p = start; p <= end; p++) {
try {
let apiUrl = `https://phimapi.com/v1/api/the-loai/${genreSlug}?page=${p}`;
const listSlugs = ['hoat-hinh', 'phim-chieu-rap', 'phim-le', 'phim-bo', 'tv-shows'];
if (listSlugs.includes(genreSlug)) {
apiUrl = `https://phimapi.com/v1/api/danh-sach/${genreSlug}?page=${p}`;
}
const res = await fetchWithTimeout(apiUrl);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const resData = await res.json();
const items = resData.data?.items || [];
for (const item of items) {
if (item.slug && !slugsToCrawl.some(s => s.slug === item.slug)) {
const existing = localMovies[item.slug];
let skip = false;
let skipReason = '';
let expectedNew = 1;
let exists = false;
if (existing) {
exists = true;
const localStatus = (existing.movie?.episode_current || '').toLowerCase();
const remoteStatus = (item.episode_current || '').toLowerCase();
const isStatusEqual = localStatus === remoteStatus && remoteStatus !== '';
const isBothCompleted = (localStatus.includes('hoàn tất') || localStatus.includes('full') || localStatus.includes('complete') || localStatus.includes('12/12')) &&
(remoteStatus.includes('hoàn tất') || remoteStatus.includes('full') || remoteStatus.includes('complete') || remoteStatus.includes('12/12'));
if (isStatusEqual || isBothCompleted) {
skip = true;
skipReason = remoteStatus.includes('hoàn tất') ? 'Phim đã hoàn tất' : 'Số tập trùng khớp';
expectedNew = 0;
}
}
slugsToCrawl.push({
raw: item.name,
slug: item.slug,
totalEpisodes: item.episode_total || (item.episode_current ? item.episode_current : '?'),
expectedNew,
exists,
skip,
skipReason
});
send({
action: 'scan_found',
name: item.name,
slug: item.slug,
totalEpisodes: item.episode_total || (item.episode_current ? item.episode_current : '?'),
expectedNew,
exists,
skip,
skipReason
});
}
}
} catch (e) {
send({ status: 'warning', message: `Không thể lấy danh sách trang ${p}: ${e.message}` });
}
}
send({ action: 'detect', count: slugsToCrawl.length, invalid: [] });
} else if (type === 'batch') {
// Xử lý crawl batch theo trang phim mới cập nhật
const start = parseInt(pageStart || '1');
const end = parseInt(pageEnd || '1');
send({ status: 'info', message: `Đang quét danh sách phim mới từ trang ${start} đến ${end}...` });
for (let p = start; p <= end; p++) {
try {
const res = await fetchWithTimeout(`https://phimapi.com/danh-sach/phim-moi-cap-nhat?page=${p}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const items = data.items || [];
for (const item of items) {
if (item.slug && !slugsToCrawl.some(s => s.slug === item.slug)) {
slugsToCrawl.push({ raw: item.name, slug: item.slug });
send({ action: 'scan_found', name: item.name, slug: item.slug });
}
}
} catch (e) {
send({ status: 'warning', message: `Không thể lấy danh sách trang ${p}: ${e.message}` });
}
}
send({ action: 'detect', count: slugsToCrawl.length, invalid: [] });
} else if (type === 'all') {
// Xử lý crawl toàn bộ phim trên API KKPhim
send({ status: 'info', message: 'Đang kết nối API KKPhim để lấy thông tin tổng số trang...' });
let totalPages = 1;
try {
const firstRes = await fetchWithTimeout(`https://phimapi.com/danh-sach/phim-moi-cap-nhat?page=1`);
if (firstRes.ok) {
const firstData = await firstRes.json();
if (firstData.pagination && firstData.pagination.totalPages) {
totalPages = firstData.pagination.totalPages;
}
}
} catch (e) {
send({ status: 'warning', message: `Không thể lấy thông tin trang 1: ${e.message}` });
}
send({ status: 'info', message: `Phát hiện tổng số ${totalPages} trang danh sách (khoảng ${totalPages * 30} phim). Đang lấy danh sách slugs toàn bộ phim...` });
// Tải danh sách song song (concurrency = 20) để cực kỳ nhanh
const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1);
const concurrency = 20;
for (let i = 0; i < pageNumbers.length; i += concurrency) {
const batch = pageNumbers.slice(i, i + concurrency);
await Promise.all(
batch.map(async (p) => {
try {
const res = await fetchWithTimeout(`https://phimapi.com/danh-sach/phim-moi-cap-nhat?page=${p}`);
if (!res.ok) return;
const data = await res.json();
const items = data.items || [];
for (const item of items) {
if (item.slug && !slugsToCrawl.some(s => s.slug === item.slug)) {
slugsToCrawl.push({ raw: item.name, slug: item.slug });
send({ action: 'scan_found', name: item.name, slug: item.slug });
}
}
} catch (e) {
// Lỗi nhỏ bỏ qua để tiếp tục
}
})
);
send({ status: 'info', message: `Đang tải danh sách: đã lấy được ${slugsToCrawl.length} phim...` });
}
send({ action: 'detect', count: slugsToCrawl.length, invalid: [] });
}
if (slugsToCrawl.length === 0) {
send({ action: 'done', message: 'Không có phim nào để crawl!' });
return;
}
if (scanOnly) {
send({ action: 'scan_complete', slugs: slugsToCrawl });
return;
}
// Elegant 1.5s delay to visually separate the Analysis/Queuing phase from the Downloading/Supabase-saving phase
await new Promise(r => setTimeout(r, 1500));
let successCount = 0;
let failCount = 0;
let skipCount = 0;
// Bắt đầu crawl từng phim một
for (let i = 0; i < slugsToCrawl.length; i++) {
const item = slugsToCrawl[i];
if (item.skip) {
skipCount++;
send({
action: 'result',
slug: item.slug,
name: item.raw,
status: 'skipped',
message: `Bỏ qua (100% không có tập mới - ${item.skipReason})`
});
continue;
}
send({
action: 'progress',
index: i + 1,
total: slugsToCrawl.length,
slug: item.slug,
name: item.raw,
status: 'processing'
});
try {
const res = await fetchWithTimeout(`https://phimapi.com/phim/${item.slug}`);
if (!res.ok) {
throw new Error(`Phim không tồn tại trên KKPhim (HTTP ${res.status})`);
}
const data = await res.json();
if (!data.status || !data.movie) {
throw new Error('Dữ liệu API KKPhim bị rỗng hoặc không đúng định dạng');
}
const crawledMovie = data.movie;
const crawledEpisodes = cleanAndFilterServers(data.episodes || []);
// Lọc tập phim nếu admin chọn cụ thể
if (Array.isArray(selectedEpisodes) && selectedEpisodes.length > 0) {
crawledEpisodes.forEach(server => {
if (server.server_data) {
server.server_data = server.server_data.filter(ep => {
const formattedName = formatEpisodeName(ep.name);
return selectedEpisodes.includes(formattedName);
});
}
});
}
const existing = localMovies[item.slug];
if (existing) {
// So sánh gộp tập phim
const { merged, addedCount } = mergeEpisodes(existing.episodes, crawledEpisodes);
const hasSelectedEpisodes = Array.isArray(selectedEpisodes) && selectedEpisodes.length > 0;
if (addedCount > 0 || hasSelectedEpisodes) {
// Có tập mới hoặc cập nhật tập đã chọn! Cập nhật dữ liệu
existing.movie = {
...existing.movie,
...crawledMovie,
episode_current: crawledMovie.episode_current || existing.movie.episode_current,
episode_total: crawledMovie.episode_total || existing.movie.episode_total || null,
modified: { time: new Date().toISOString() }
};
existing.episodes = merged;
await saveLocalMovie(item.slug, existing);
successCount++;
send({
action: 'result',
slug: item.slug,
name: crawledMovie.name,
status: 'updated',
added: addedCount,
message: hasSelectedEpisodes && addedCount === 0
? `Thành công (Cập nhật liên kết ${selectedEpisodes.length} tập)`
: `Thành công (Cập nhật +${addedCount} tập mới)`
});
} else {
// Không có tập mới nào, bỏ qua
skipCount++;
send({
action: 'result',
slug: item.slug,
name: crawledMovie.name,
status: 'skipped',
message: 'Bỏ qua (Đã đầy đủ tập, không có tập mới)'
});
}
} else {
// Phim mới hoàn toàn
crawledEpisodes.forEach(srv => {
if (srv.server_data) {
srv.server_data.forEach(ep => {
ep.name = formatEpisodeName(ep.name);
});
}
});
const newMovie = {
movie: crawledMovie,
episodes: crawledEpisodes
};
await saveLocalMovie(item.slug, newMovie);
successCount++;
send({
action: 'result',
slug: item.slug,
name: crawledMovie.name,
status: 'success',
message: 'Thành công (Phim mới)'
});
}
// Tự động đồng bộ các danh mục Thể loại, Quốc gia, Diễn viên và Server ngay lập tức!
await syncFromMovies('genres');
await syncFromMovies('countries');
await syncFromMovies('actors');
await syncFromMovies('servers');
} catch (e) {
failCount++;
send({
action: 'result',
slug: item.slug,
name: item.raw,
status: 'failed',
message: `Thất bại: ${e.message}`
});
}
// Tránh bị rate limit bởi server KKPhim
await new Promise(r => setTimeout(r, 400));
}
send({
action: 'complete',
success: successCount,
failed: failCount,
skipped: skipCount
});
} catch (err) {
send({ action: 'error', message: err.message });
} finally {
try {
controller.close();
} catch (e) {
// Ignore if already closed
}
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
}