This commit is contained in:
2026-04-15 17:47:48 +09:00
parent d56f9846d5
commit 301b912e6e
2 changed files with 260 additions and 121 deletions
+17
View File
@@ -0,0 +1,17 @@
# Netscape HTTP Cookie File
# This file is generated by yt-dlp. Do not edit.
.instagram.com TRUE / TRUE 1810802330 csrftoken cqJtFS0VgmeCPjjmVLovvPJXWA8L9cEj
.instagram.com TRUE / TRUE 1793493582 datr TTLXaMExDmYry2rCMm1_8VTv
.instagram.com TRUE / TRUE 1790469584 ig_did B4AF5095-476B-456B-A553-C6494A4C132F
.instagram.com TRUE / TRUE 1790469583 ig_nrcb 1
.instagram.com TRUE / TRUE 1793493571 mid aNcyQwALAAG93crkt_hpz8Bjf_C-
.instagram.com TRUE / TRUE 1794698883 ps_l 1
.instagram.com TRUE / TRUE 1794698883 ps_n 1
.instagram.com TRUE / TRUE 0 rur "CCO\0542032189992\0541797490351:01fe0cab60bd4e111fcf77da34894f19d48758b1e2327dd4acd78bd64ee59cf6846f01fa"
.youtube.com TRUE / FALSE 0 PREF hl=en&tz=UTC
.youtube.com TRUE / TRUE 0 SOCS CAI
.youtube.com TRUE / TRUE 1781777544 VISITOR_INFO1_LIVE d6cJqkeLJ6I
.youtube.com TRUE / TRUE 1781777544 VISITOR_PRIVACY_METADATA CgJLUhIEGgAgHQ%3D%3D
.youtube.com TRUE / TRUE 0 YSC k9F7AmifQN8
.youtube.com TRUE / TRUE 1781777544 __Secure-ROLLOUT_TOKEN CJjosKqwurikwwEQitTOib7IkQMYgovA8fbLkQM%3D
+243 -121
View File
@@ -1,12 +1,19 @@
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import net.dv8tion.jda.api.interactions.components.selections.StringSelectMenu;
import net.dv8tion.jda.api.requests.GatewayIntent;
import org.jetbrains.annotations.NotNull;
import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.regex.Matcher;
@@ -14,160 +21,275 @@ import java.util.regex.Pattern;
public class DiscordDownloadBot extends ListenerAdapter {
// 🔴 [필수] 여기에 디스코드 봇 토큰을 붙여넣으세요
// 🔴 토큰 확인
private static final String BOT_TOKEN = "MTQ0Nzg1NjI1NTY4NjE0ODEzOA.GpvmOr.KRTrSGh1GrmlF_oHY1brKZbAXb3erfHBlPXHEI";
// yt-dlp.exe 경로 (프로젝트 최상위 폴더 기준)
private static final String YTDLP_PATH = System.getProperty("user.dir") + File.separator + "yt-dlp.exe";
private static final String BASE_DOWNLOAD_DIR = System.getProperty("user.dir") + File.separator + "Downloads";
// 저장할 폴더 (프로젝트 폴더 아래 'Downloads')
private static final String DOWNLOAD_DIR = System.getProperty("user.dir") + File.separator + "Downloads";
// 동시에 3개까지만 다운로드 처리 (서버 부하 방지)
private final ExecutorService executor = Executors.newFixedThreadPool(3);
private static final Map<String, String> pendingUrls = new HashMap<>();
private static final Map<String, Boolean> isCreatingFolder = new HashMap<>();
public static void main(String[] args) {
// 1. 필수 파일 확인
if (!new File(YTDLP_PATH).exists()) {
System.err.println("❌ 오류: yt-dlp.exe 파일을 프로젝트 폴더(pom.xml 옆)에 넣어주세요!");
return;
}
File folder = new File(DOWNLOAD_DIR);
if (!folder.exists()) {
boolean success = folder.mkdirs(); // 폴더가 없으면 생성
if (success) {
System.out.println("✅ 다운로드 폴더를 새로 만들었습니다: " + DOWNLOAD_DIR);
} else {
System.out.println("⚠️ 폴더 생성 실패! 권한을 확인하세요.");
}
}
// 2. 봇 실행
new File(BASE_DOWNLOAD_DIR).mkdirs();
try {
JDABuilder.createDefault(BOT_TOKEN)
.enableIntents(GatewayIntent.MESSAGE_CONTENT) // 메시지 내용 읽기 권한
.enableIntents(GatewayIntent.MESSAGE_CONTENT)
.addEventListeners(new DiscordDownloadBot())
.build();
System.out.println("🤖 봇 실행 완료! 유튜브/인스타 링크를 감지합니다.");
System.out.println("📂 저장 경로: " + DOWNLOAD_DIR);
System.out.println("🤖 봇 실행 완료! (더블 탭 전략)");
} catch (Exception e) {
System.err.println("❌ 봇 실행 실패: 토큰이 올바른지 확인하세요.");
e.printStackTrace();
}
}
@Override
public void onMessageReceived(@NotNull MessageReceivedEvent event) {
// 1. 봇 자신의 메시지는 무시
if (event.getAuthor().isBot()) return;
// 2. 메시지 내용 가져오기
String userId = event.getAuthor().getId();
String message = event.getMessage().getContentRaw();
System.out.println("👂 감지된 메시지: " + message);
// 3. URL 추출 (작성하신 extractUrl 함수 사용)
if (isCreatingFolder.getOrDefault(userId, false)) {
createNewFolderAndDownload(event, message);
return;
}
String foundUrl = extractUrl(message);
// 4. 유효한 링크가 발견되면 다운로드 시작
if (foundUrl != null) {
System.out.println("✅ 다운로드 대상 링크 발견: " + foundUrl);
event.getChannel().sendMessage("📥 영상을 감지했습니다! 다운로드를 시도합니다... (" + foundUrl + ")").queue();
// 5. [핵심] 별도 스레드에서 다운로드 실행 (봇 멈춤 방지)
executor.submit(() -> {
processDownload(foundUrl, event);
});
pendingUrls.put(userId, foundUrl);
sendFolderSelectionMenu(event);
}
}
// 유튜브, 인스타, 틱톡 등의 URL만 쏙 뽑아내는 함수
private void sendFolderSelectionMenu(MessageReceivedEvent event) {
File baseDir = new File(BASE_DOWNLOAD_DIR);
File[] directories = baseDir.listFiles(File::isDirectory);
StringSelectMenu.Builder menuBuilder = StringSelectMenu.create("folder-select").setPlaceholder("📂 저장할 폴더를 선택하세요");
if (directories != null) {
for (File dir : directories) {
if (menuBuilder.getOptions().size() >= 24) break;
menuBuilder.addOption("📂 " + dir.getName(), dir.getName());
}
}
menuBuilder.addOption(" 새 폴더 만들기", "create_new_folder");
event.getChannel().sendMessage("영상 링크를 확인했습니다! **어디에 저장할까요?**").addActionRow(menuBuilder.build()).queue();
}
@Override
public void onStringSelectInteraction(StringSelectInteractionEvent event) {
if (!event.getComponentId().equals("folder-select")) return;
String selectedValue = event.getValues().get(0);
String userId = event.getUser().getId();
String url = pendingUrls.get(userId);
if (url == null) {
event.reply("❌ 링크 정보가 만료되었습니다.").setEphemeral(true).queue();
return;
}
if (selectedValue.equals("create_new_folder")) {
isCreatingFolder.put(userId, true);
event.reply("📝 **새 폴더 이름을 입력해주세요.**").setEphemeral(true).queue();
} else {
event.reply("✅ **" + selectedValue + "** 폴더에 저장을 시작합니다!").queue();
String targetPath = BASE_DOWNLOAD_DIR + File.separator + selectedValue;
executor.submit(() -> processDownload(url, event.getMessageChannel(), targetPath));
pendingUrls.remove(userId);
}
}
private void createNewFolderAndDownload(MessageReceivedEvent event, String folderName) {
String userId = event.getAuthor().getId();
folderName = folderName.replaceAll("[^a-zA-Z0-9가-힣_\\-]", "");
if (folderName.isEmpty()) folderName = "NewFolder";
String newFolderPath = BASE_DOWNLOAD_DIR + File.separator + folderName;
new File(newFolderPath).mkdirs();
String url = pendingUrls.get(userId);
if (url != null) {
event.getChannel().sendMessage("📥 **" + folderName + "** 폴더에 다운로드 시작!").queue();
executor.submit(() -> processDownload(url, event.getChannel(), newFolderPath));
}
isCreatingFolder.remove(userId);
pendingUrls.remove(userId);
}
// ⬇️ [최종 전략] 1차(영상) 시도 -> 실패시 -> 2차(사진) 시도
private void processDownload(String url, net.dv8tion.jda.api.entities.channel.middleman.MessageChannel channel, String saveDir) {
Message progressMessage = null;
try {
progressMessage = channel.sendMessage("⏳ **다운로드 시도 중...**").complete();
// 1. URL에서 번호 뽑기
String targetIndexStr = getImgIndex(url);
String targetIndex = (targetIndexStr != null) ? targetIndexStr : "1";
System.out.println("🎯 타겟 번호: " + targetIndex + "");
// ==========================================
// 👊 1타: 동영상이라고 가정하고 다운로드 시도
// ==========================================
boolean isVideoSuccess = tryDownloadAsVideo(url, saveDir, targetIndex);
if (isVideoSuccess) {
progressMessage.editMessage("✅ **동영상 다운로드 완료!**\n📂 폴더: `" + new File(saveDir).getName() + "`").queue();
return;
}
System.out.println("⚠️ 동영상 다운로드 실패(사진인듯). 2차 시도 시작...");
// ==========================================
// 👊 2타: 사진이라고 가정하고 다운로드 시도
// ==========================================
boolean isImageSuccess = tryDownloadAsImage(url, saveDir, targetIndex);
if (isImageSuccess) {
progressMessage.editMessage("✅ **사진 다운로드 완료!**\n📂 폴더: `" + new File(saveDir).getName() + "`").queue();
} else {
progressMessage.editMessage("❌ **최종 실패.** (쿠키 만료 또는 게시물 삭제됨)").queue();
}
} catch (Exception e) {
if (progressMessage != null) progressMessage.editMessage("🔥 오류: " + e.getMessage()).queue();
e.printStackTrace();
}
}
// 🎥 1타: 동영상 모드 (Index 지정)
private boolean tryDownloadAsVideo(String url, String saveDir, String index) {
try {
List<String> cmdList = new ArrayList<>();
cmdList.add(YTDLP_PATH);
cmdList.add("--cookies");
cmdList.add("cookies.txt");
cmdList.add("--user-agent"); // 동영상은 PC UA가 더 안정적일 때가 있음
cmdList.add("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
// 🎯 인덱스 지정
cmdList.add("--playlist-items");
cmdList.add(index);
// 포맷 강제 (동영상이 아니면 여기서 에러 나고 죽음 -> catch로 넘어감)
cmdList.add("-f");
cmdList.add("bestvideo+bestaudio/best");
cmdList.add("--merge-output-format");
cmdList.add("mp4");
cmdList.add("-o");
cmdList.add(saveDir + File.separator + "%(title)s_%(id)s_Video" + index + ".%(ext)s");
cmdList.add(url);
System.out.println("🚀 [1타] 동영상 시도: " + index + "");
ProcessBuilder pb = new ProcessBuilder(cmdList);
pb.redirectErrorStream(true);
Process process = pb.start();
// 로그 읽기 (성공 여부 판단)
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
boolean success = false;
while ((line = reader.readLine()) != null) {
// System.out.println("[Video] " + line); // 너무 시끄러우면 주석 처리
if (line.contains("100%") || line.contains("Destination")) {
success = true;
}
}
process.waitFor();
// 파일이 진짜 생겼는지 확인
if (success) return true;
// yt-dlp가 성공했다고 구라칠 수도 있으니 파일 확인
return checkFileExists(saveDir, "Video" + index);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
// 🖼️ 2타: 사진 모드 (Index 지정 + 썸네일 강제 저장)
private boolean tryDownloadAsImage(String url, String saveDir, String index) {
try {
List<String> cmdList = new ArrayList<>();
cmdList.add(YTDLP_PATH);
cmdList.add("--cookies");
cmdList.add("cookies.txt");
cmdList.add("--user-agent"); // 사진은 모바일 UA가 짱임
cmdList.add("Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1");
// 🎯 인덱스 지정
cmdList.add("--playlist-items");
cmdList.add(index);
// ⭐ [핵심] "동영상 말고 썸네일(사진)을 파일로 써라"
cmdList.add("--write-thumbnail");
cmdList.add("--skip-download"); // 동영상 다운로드는 생략 (어차피 없으니까)
cmdList.add("--convert-thumbnails");
cmdList.add("jpg");
cmdList.add("--ignore-errors"); // 에러 무시 (No video 에러 씹기)
cmdList.add("-o");
// 파일명에 ImageTag를 붙여서 식별
cmdList.add(saveDir + File.separator + "%(title)s_%(id)s_Image" + index + ".%(ext)s");
cmdList.add(url);
System.out.println("🚀 [2타] 사진 시도: " + index + "");
ProcessBuilder pb = new ProcessBuilder(cmdList);
pb.redirectErrorStream(true);
Process process = pb.start();
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
// System.out.println("[Image] " + line);
}
process.waitFor();
// 파일이 생겼는지 확인 (이게 제일 확실함)
return checkFileExists(saveDir, "Image" + index);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
// 파일 생성 확인 함수
private boolean checkFileExists(String saveDir, String keyword) {
File dir = new File(saveDir);
File[] files = dir.listFiles();
if (files == null) return false;
for (File f : files) {
// 방금 다운로드 시도한 파일(키워드 포함)이 존재하면 성공
if (f.getName().contains(keyword)) {
return true;
}
}
return false;
}
private String extractUrl(String text) {
// http 또는 https로 시작하는 주소 패턴
String regex = "(https?://\\S+)";
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(text);
Matcher matcher = Pattern.compile("(https?://\\S+)").matcher(text);
if (matcher.find()) {
String foundUrl = matcher.group(1);
// 유튜브, 인스타, 틱톡 주소인지 한번 더 체크 (원하면 이 부분 삭제해서 모든 링크 허용 가능)
if (foundUrl.contains("youtube.com") || foundUrl.contains("youtu.be") ||
foundUrl.contains("instagram.com") || foundUrl.contains("tiktok.com")) {
return foundUrl;
String found = matcher.group(1);
if (found.contains("youtube.com") || found.contains("youtu.be") || found.contains("instagram.com")) {
return found;
}
}
return null;
}
private void processDownload(String url, MessageReceivedEvent event) {
// 진행 상황을 표시할 메시지 객체
net.dv8tion.jda.api.entities.Message progressMessage = null;
private String getImgIndex(String url) {
try {
// 1. 처음에 "다운로드 시작" 메시지를 보내고, 그 메시지 객체를 가져옵니다.
progressMessage = event.getChannel().sendMessage("⏳ **다운로드 준비 중...**").complete();
File dir = new File(DOWNLOAD_DIR);
if (!dir.exists()) dir.mkdirs();
String outputPath = DOWNLOAD_DIR + File.separator + "%(title)s.%(ext)s";
// 2. 명령어 설정 (--newline 옵션 추가가 핵심!)
String[] command = {
YTDLP_PATH,
"--newline", // 👈 [중요] 진행률을 한 줄씩 출력하게 함
"--no-warnings",
"-f", "bestvideo+bestaudio/best",
"--merge-output-format", "mp4",
"-o", outputPath,
url
};
ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(true); // 에러도 같이 읽음
Process process = pb.start();
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
// 시간 체크용 변수 (너무 잦은 업데이트 방지)
long lastUpdateTime = 0;
// 3. yt-dlp의 출력을 한 줄씩 읽음
while ((line = reader.readLine()) != null) {
System.out.println("[yt-dlp] " + line); // 콘솔에도 출력
// "[download] 25.4%" 같은 패턴을 찾음
if (line.contains("[download]") && line.contains("%")) {
long currentTime = System.currentTimeMillis();
// 3초(3000ms)가 지났을 때만 디스코드 메시지 수정
if (currentTime - lastUpdateTime > 3000) {
// 정규식으로 숫자만 쏙 뽑기 (예: 25.4%)
Matcher matcher = Pattern.compile("(\\d+\\.\\d+%)").matcher(line);
if (matcher.find()) {
String percent = matcher.group(1);
progressMessage.editMessage("📥 **다운로드 중...** " + percent).queue();
lastUpdateTime = currentTime;
}
}
}
}
int exitCode = process.waitFor();
// 4. 완료 시 메시지 수정
if (exitCode == 0) {
progressMessage.editMessage("✅ **다운로드 완료!**\n(파일은 서버 컴퓨터의 `Downloads` 폴더에 저장되었습니다.)").queue();
} else {
progressMessage.editMessage("❌ **다운로드 실패.** 콘솔 로그를 확인하세요.").queue();
}
} catch (Exception e) {
e.printStackTrace();
if (progressMessage != null) {
progressMessage.editMessage("🔥 **오류 발생:** " + e.getMessage()).queue();
}
}
Matcher m = Pattern.compile("[?&]img_index=(\\d+)").matcher(url);
if (m.find()) return m.group(1);
} catch (Exception e) { /* 무시 */ }
return null;
}
}