|
1 | 1 | import * as cheerio from 'cheerio'; |
2 | | -import puppeteer from 'puppeteer'; |
| 2 | +import puppeteer, { Browser, Page } from 'puppeteer'; |
3 | 3 |
|
4 | 4 | import { getInvalidKYSongsDB, getSongsKyNullDB } from '@/supabase/getDB'; |
5 | 5 | import { postInvalidKYSongsDB } from '@/supabase/postDB'; |
6 | 6 | import { updateSongsKyDB } from '@/supabase/updateDB'; |
7 | 7 | import { Song } from '@/types'; |
8 | | -import { saveCrawlYoutubeFailedKYSongs, updateDataLog } from '@/utils/logData'; |
9 | 8 |
|
10 | 9 | import { isValidKYExistNumber } from './isValidKYExistNumber'; |
11 | 10 |
|
12 | | -// youtube에서 KY 노래방 번호 크롤링 |
13 | | -// crawlYoutubeValid에서 진행하는 실제 사이트 검증도 포함 |
| 11 | +// --- Constants --- |
| 12 | +const BASE_YOUTUBE_SEARCH_URL = 'https://www.youtube.com/@KARAOKEKY/search'; |
| 13 | +// --- Helper Functions --- |
14 | 14 |
|
15 | | -// action 우분투 환경에서의 호환을 위해 추가 |
16 | | -const browser = await puppeteer.launch({ |
17 | | - headless: true, |
18 | | -}); |
19 | | - |
20 | | -const page = await browser.newPage(); |
21 | | - |
22 | | -const baseUrl = 'https://www.youtube.com/@KARAOKEKY/search'; |
23 | | - |
24 | | -const scrapeSongNumber = async (query: string) => { |
25 | | - const searchUrl = `${baseUrl}?query=${encodeURIComponent(query)}`; |
26 | | - |
27 | | - // page.goto의 waitUntil 문제였음! |
28 | | - await page.goto(searchUrl, { |
29 | | - waitUntil: 'networkidle2', |
30 | | - timeout: 0, |
31 | | - }); |
| 15 | +/** |
| 16 | + * 텍스트에서 KY 노래방 번호를 추출합니다. |
| 17 | + */ |
| 18 | +const extractKaraokeNumber = (title: string): string | null => { |
| 19 | + const matchResult = title.match(/KY\.\s*(\d{2,5})\)/); |
| 20 | + return matchResult ? matchResult[1] : null; |
| 21 | +}; |
32 | 22 |
|
33 | | - const html = await page.content(); |
34 | | - const $ = cheerio.load(html); |
| 23 | +/** |
| 24 | + * 유튜브 검색 결과 페이지에서 노래 번호를 스크래핑합니다. |
| 25 | + */ |
| 26 | +const scrapeSongNumber = async (page: Page, query: string): Promise<string | null> => { |
| 27 | + const searchUrl = `${BASE_YOUTUBE_SEARCH_URL}?query=${encodeURIComponent(query)}`; |
35 | 28 |
|
36 | | - // id contents 의 첫번째 ytd-item-section-renderer 찾기 |
37 | | - // const firstItem = $("#contents ytd-item-section-renderer").first(); |
| 29 | + try { |
| 30 | + // waitUntil을 통해 네트워크가 안정될 때까지 대기 |
| 31 | + // 30초 타임아웃 설정 (무한 대기 방지) |
| 32 | + await page.goto(searchUrl, { |
| 33 | + waitUntil: 'networkidle2', |
| 34 | + // timeout: 0, |
| 35 | + }); |
38 | 36 |
|
39 | | - const firstItem = $('ytd-video-renderer').first(); |
| 37 | + const html = await page.content(); |
| 38 | + const $ = cheerio.load(html); |
40 | 39 |
|
41 | | - // yt-formatted-string 찾기 |
42 | | - const title = firstItem.find('yt-formatted-string').first().text().trim(); |
| 40 | + const firstItem = $('ytd-video-renderer').first(); |
43 | 41 |
|
44 | | - const karaokeNumber = extractKaraokeNumber(title); |
| 42 | + // 검색 결과가 없는 경우 처리 |
| 43 | + if (firstItem.length === 0) { |
| 44 | + return null; |
| 45 | + } |
45 | 46 |
|
46 | | - return karaokeNumber; |
| 47 | + const title = firstItem.find('yt-formatted-string').first().text().trim(); |
| 48 | + return extractKaraokeNumber(title); |
| 49 | + } catch (error) { |
| 50 | + console.warn(`[Scraping Failed] Query: ${query}`, error); |
| 51 | + return null; |
| 52 | + } |
47 | 53 | }; |
48 | 54 |
|
49 | | -const extractKaraokeNumber = (title: string) => { |
50 | | - // KY. 찾고 ) 가 올때까지 찾기 |
51 | | - const matchResult = title.match(/KY\.\s*(\d{2,5})\)/); |
52 | | - const karaokeNumber = matchResult ? matchResult[1] : null; |
53 | | - return karaokeNumber; |
| 55 | +/** |
| 56 | + * 성공한 데이터를 DB에 업데이트하고 로그를 남깁니다. |
| 57 | + */ |
| 58 | +const handleSuccess = async (song: Song, kyNum: string) => { |
| 59 | + const result = await updateSongsKyDB({ ...song, num_ky: kyNum }); |
| 60 | + // console.log(`[Update Success] ${song.title}: ${kyNum}`, result); // 로그 너무 많으면 주석 처리 |
| 61 | + // updateDataLog(result.success, 'crawlYoutubeSuccess.txt'); |
54 | 62 | }; |
55 | 63 |
|
56 | | -const updateData = async (data: Song) => { |
57 | | - const result = await updateSongsKyDB(data); |
58 | | - console.log(result); |
59 | | - updateDataLog(result.success, 'crawlYoutubeSuccess.txt'); |
60 | | - updateDataLog(result.failed, 'crawlYoutubeFailed.txt'); |
| 64 | +/** |
| 65 | + * 실패한 데이터를 Invalid DB에 저장하고 로그를 남깁니다. |
| 66 | + */ |
| 67 | +const handleFailure = async (song: Song) => { |
| 68 | + await postInvalidKYSongsDB(song); |
| 69 | + // updateDataLog(false, 'crawlYoutubeFailed.txt'); // false 로그 처리 방식에 따라 수정 필요 |
61 | 70 | }; |
62 | 71 |
|
63 | | -// failedSongs을 가져와서 실패한 노래를 건너뛰는 게 아니라 실패 시 update_date를 수정해 작업 순위를 뒤로 미룬다면? |
64 | | -const data = await getSongsKyNullDB(); |
65 | | -const failedSongs = await getInvalidKYSongsDB(); |
| 72 | +// --- Main Logic --- |
66 | 73 |
|
67 | | -console.log('getSongsKyNullDB : ', data.length); |
68 | | -console.log('failedSongs : ', failedSongs.length); |
69 | | -let index = 0; |
70 | | -let successCount = 0; |
| 74 | +const main = async () => { |
| 75 | + console.log('🚀 크롤링 작업을 시작합니다...'); |
71 | 76 |
|
72 | | -for (const song of data) { |
73 | | - if (failedSongs.find(failedSong => failedSong.id === song.id)) { |
74 | | - continue; |
75 | | - } |
76 | | - const query = song.title + '-' + song.artist; |
| 77 | + // 1. 브라우저 초기화 |
| 78 | + const browser = await puppeteer.launch({ |
| 79 | + headless: true, |
| 80 | + args: ['--no-sandbox', '--disable-setuid-sandbox'], // 리눅스 환경 호환성 |
| 81 | + }); |
77 | 82 |
|
78 | | - let resultKyNum = null; |
79 | 83 | try { |
80 | | - resultKyNum = await scrapeSongNumber(query); |
81 | | - } catch (error) { |
82 | | - continue; |
83 | | - } |
84 | | - |
85 | | - if (resultKyNum) { |
86 | | - let isValid = true; |
87 | | - try { |
88 | | - isValid = await isValidKYExistNumber(page, resultKyNum, song.title, song.artist); |
89 | | - } catch (error) { |
90 | | - continue; |
| 84 | + const page = await browser.newPage(); |
| 85 | + |
| 86 | + // 2. 데이터 가져오기 |
| 87 | + // Promise.all로 병렬 요청하여 대기 시간 단축 |
| 88 | + const [targetSongs, failedSongs] = await Promise.all([ |
| 89 | + getSongsKyNullDB(), |
| 90 | + getInvalidKYSongsDB(), |
| 91 | + ]); |
| 92 | + |
| 93 | + console.log(`📊 ky가 null인 대상 곡: ${targetSongs.length}개`); |
| 94 | + console.log(`🚫 이미 실패한 곡(유효하지 않은 KY 노래방 번호): ${failedSongs.length}개`); |
| 95 | + console.log(`🎯 추가 가능한 최대 곡 개수: ${targetSongs.length - failedSongs.length}개`); |
| 96 | + |
| 97 | + // 3. 최적화: 실패한 곡 ID를 Set으로 변환 (검색 속도 O(1)로 향상) |
| 98 | + const failedSongIds = new Set(failedSongs.map(s => s.id)); |
| 99 | + |
| 100 | + let processedCount = 0; |
| 101 | + let successCount = 0; |
| 102 | + |
| 103 | + // 4. 순차 처리 루프 |
| 104 | + for (const song of targetSongs) { |
| 105 | + processedCount++; |
| 106 | + const query = `${song.title}-${song.artist}`; |
| 107 | + |
| 108 | + // 4-1. 이미 실패했던 곡은 스킵 |
| 109 | + if (failedSongIds.has(song.id)) { |
| 110 | + continue; |
| 111 | + } |
| 112 | + |
| 113 | + console.log(`[${processedCount}/${targetSongs.length}] 검색 중: ${query}`); |
| 114 | + |
| 115 | + // 4-2. 스크래핑 시도 |
| 116 | + const resultKyNum = await scrapeSongNumber(page, query); |
| 117 | + |
| 118 | + if (!resultKyNum) { |
| 119 | + // 검색 결과 없음 -> 실패 처리 |
| 120 | + console.log(`❌ 검색 결과 없음: ${query}`); |
| 121 | + await handleFailure(song); |
| 122 | + continue; |
| 123 | + } |
| 124 | + |
| 125 | + // 4-3. 번호 유효성 검증 (실제 존재하는 번호인지 2차 확인) |
| 126 | + let isValid = false; |
| 127 | + try { |
| 128 | + isValid = await isValidKYExistNumber(page, resultKyNum, song.title, song.artist); |
| 129 | + } catch (error) { |
| 130 | + console.error(`❌ 검증 중 에러 발생: ${query}`, error); |
| 131 | + // 검증 에러 시 일단 실패 처리하거나 continue |
| 132 | + continue; |
| 133 | + } |
| 134 | + |
| 135 | + if (isValid) { |
| 136 | + // 성공 처리 |
| 137 | + await handleSuccess(song, resultKyNum); |
| 138 | + successCount++; |
| 139 | + console.log(`✅ 업데이트 완료: ${resultKyNum}`); |
| 140 | + } else { |
| 141 | + // 유효하지 않은 번호 -> 실패 처리 |
| 142 | + await handleFailure(song); |
| 143 | + console.log(`⚠️ 유효하지 않은 번호: ${resultKyNum}`); |
| 144 | + } |
91 | 145 | } |
92 | 146 |
|
93 | | - if (!isValid) { |
94 | | - await postInvalidKYSongsDB(song); |
95 | | - continue; |
96 | | - } else { |
97 | | - await updateData({ ...song, num_ky: resultKyNum }); |
98 | | - console.log('update song : ', resultKyNum); |
99 | | - successCount++; |
100 | | - } |
101 | | - } else await postInvalidKYSongsDB(song); |
102 | | - |
103 | | - index++; |
104 | | - console.log(query); |
105 | | - console.log('scrapeSongNumber : ', index); |
106 | | - console.log('successCount : ', successCount); |
107 | | -} |
| 147 | + console.log('------------------------------------------------'); |
| 148 | + console.log(`🎉 모든 작업 완료! 총 성공: ${successCount}건`); |
| 149 | + } catch (error) { |
| 150 | + console.error('🔥 치명적인 에러 발생:', error); |
| 151 | + } finally { |
| 152 | + // 5. 종료 처리: 에러가 나든 안 나든 브라우저는 반드시 닫음 |
| 153 | + await browser.close(); |
| 154 | + console.log('🔒 브라우저 종료됨'); |
| 155 | + } |
| 156 | +}; |
108 | 157 |
|
109 | | -browser.close(); |
| 158 | +// 스크립트 실행 |
| 159 | +main(); |
0 commit comments