From 0ee587ad08e1737c0a4d545082d15ec513a01395 Mon Sep 17 00:00:00 2001 From: caadiq Date: Sat, 10 Jan 2026 17:22:45 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C?= =?UTF-8?q?=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scrape_all.cjs, scrape_all.js, scrape_search.cjs 삭제 (미사용) - scrape_log.txt, scrape_search_log.txt 삭제 (로그 파일) - extract_youtube_from_x.js 삭제 (일회성 스크립트) --- backend/extract_youtube_from_x.js | 317 ------------------------------ backend/scrape_all.cjs | 241 ----------------------- backend/scrape_all.js | 239 ---------------------- backend/scrape_log.txt | 111 ----------- backend/scrape_search.cjs | 229 --------------------- backend/scrape_search_log.txt | 34 ---- 6 files changed, 1171 deletions(-) delete mode 100644 backend/extract_youtube_from_x.js delete mode 100644 backend/scrape_all.cjs delete mode 100644 backend/scrape_all.js delete mode 100644 backend/scrape_log.txt delete mode 100644 backend/scrape_search.cjs delete mode 100644 backend/scrape_search_log.txt diff --git a/backend/extract_youtube_from_x.js b/backend/extract_youtube_from_x.js deleted file mode 100644 index 9a612b1..0000000 --- a/backend/extract_youtube_from_x.js +++ /dev/null @@ -1,317 +0,0 @@ -/** - * 기존 X 일정에서 유튜브 링크를 추출하여 일정으로 추가하는 스크립트 - * - * - category_id=12 (X) 인 일정 중 description에 유튜브 링크가 포함된 것을 찾음 - * - 유튜브 영상 정보를 YouTube API로 조회 - * - 기존 유튜브 봇이 관리하지 않는 채널의 영상만 일정으로 추가 - */ - -import pool from "./lib/db.js"; -import { addOrUpdateSchedule } from "./services/meilisearch.js"; - -// YouTube API 키 -const YOUTUBE_API_KEY = - process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM"; - -// 기존 유튜브 봇이 관리하는 채널 ID 목록 -const MANAGED_CHANNEL_IDS = [ - "UCXbRURMKT3H_w8dT-DWLIxA", // fromis_9 - "UCeUJ8B3krxw8zuDi19AlhaA", // 스프 : 스튜디오 프로미스나인 - "UCtfyAiqf095_0_ux8ruwGfA", // MUSINSA TV -]; - -// 유튜브 카테고리 ID -const YOUTUBE_CATEGORY_ID = 7; - -/** - * 텍스트에서 유튜브 videoId 추출 - */ -function extractYoutubeVideoIds(text) { - if (!text) return []; - - const videoIds = []; - - // youtu.be/{videoId} 형식 - const shortMatches = text.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/g); - if (shortMatches) { - shortMatches.forEach((m) => { - const id = m.replace("youtu.be/", ""); - if (id && id.length === 11) videoIds.push(id); - }); - } - - // youtube.com/watch?v={videoId} 형식 - const watchMatches = text.match( - /youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/g - ); - if (watchMatches) { - watchMatches.forEach((m) => { - const id = m.match(/v=([a-zA-Z0-9_-]{11})/)?.[1]; - if (id) videoIds.push(id); - }); - } - - // youtube.com/shorts/{videoId} 형식 - const shortsMatches = text.match( - /youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/g - ); - if (shortsMatches) { - shortsMatches.forEach((m) => { - const id = m.match(/shorts\/([a-zA-Z0-9_-]{11})/)?.[1]; - if (id) videoIds.push(id); - }); - } - - // 중복 제거 - return [...new Set(videoIds)]; -} - -/** - * YouTube API로 영상 정보 조회 - */ -async function fetchVideoInfo(videoId) { - try { - const url = `https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=${videoId}&key=${YOUTUBE_API_KEY}`; - const response = await fetch(url); - const data = await response.json(); - - if (!data.items || data.items.length === 0) { - return null; - } - - const video = data.items[0]; - const snippet = video.snippet; - const duration = video.contentDetails?.duration || ""; - - // duration 파싱 (PT1M30S → 초) - const durationMatch = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); - let seconds = 0; - if (durationMatch) { - seconds = - parseInt(durationMatch[1] || 0) * 3600 + - parseInt(durationMatch[2] || 0) * 60 + - parseInt(durationMatch[3] || 0); - } - - const isShorts = seconds > 0 && seconds <= 60; - - return { - videoId, - title: snippet.title, - description: snippet.description || "", - channelId: snippet.channelId, - channelTitle: snippet.channelTitle, - publishedAt: new Date(snippet.publishedAt), - isShorts, - videoUrl: isShorts - ? `https://www.youtube.com/shorts/${videoId}` - : `https://www.youtube.com/watch?v=${videoId}`, - }; - } catch (error) { - console.error(`영상 정보 조회 오류 (${videoId}):`, error.message); - return null; - } -} - -/** - * UTC → KST 변환 - */ -function toKST(utcDate) { - const date = new Date(utcDate); - return new Date(date.getTime() + 9 * 60 * 60 * 1000); -} - -/** - * 날짜를 YYYY-MM-DD 형식으로 변환 - */ -function formatDate(date) { - return date.toISOString().split("T")[0]; -} - -/** - * 시간을 HH:MM:SS 형식으로 변환 - */ -function formatTime(date) { - return date.toTimeString().split(" ")[0]; -} - -/** - * description에서 멤버 ID 추출 - */ -async function extractMemberIds(text) { - if (!text) return []; - - const [members] = await pool.query("SELECT id, name FROM members"); - const memberIds = []; - - for (const member of members) { - if (text.includes(member.name)) { - memberIds.push(member.id); - } - } - - return memberIds; -} - -/** - * 유튜브 영상을 일정으로 추가 - */ -async function createScheduleFromVideo(video, memberIds = []) { - // source_url로 중복 체크 - const [existing] = await pool.query( - "SELECT id FROM schedules WHERE source_url = ?", - [video.videoUrl] - ); - - if (existing.length > 0) { - return null; // 이미 존재 - } - - const kstDate = toKST(video.publishedAt); - const date = formatDate(kstDate); - const time = formatTime(kstDate); - - // 일정 생성 - const [result] = await pool.query( - `INSERT INTO schedules (title, date, time, category_id, source_url, source_name) - VALUES (?, ?, ?, ?, ?, NULL)`, - [video.title, date, time, YOUTUBE_CATEGORY_ID, video.videoUrl] - ); - - const scheduleId = result.insertId; - - // 멤버 연결 - if (memberIds.length > 0) { - const uniqueMemberIds = [...new Set(memberIds)]; - const memberValues = uniqueMemberIds.map((memberId) => [ - scheduleId, - memberId, - ]); - await pool.query( - `INSERT INTO schedule_members (schedule_id, member_id) VALUES ?`, - [memberValues] - ); - } - - // Meilisearch에 동기화 - try { - const [categoryInfo] = await pool.query( - "SELECT name, color FROM schedule_categories WHERE id = ?", - [YOUTUBE_CATEGORY_ID] - ); - const [memberInfo] = await pool.query( - "SELECT id, name FROM members WHERE id IN (?)", - [memberIds.length > 0 ? [...new Set(memberIds)] : [0]] - ); - await addOrUpdateSchedule({ - id: scheduleId, - title: video.title, - description: "", - date, - time, - category_id: YOUTUBE_CATEGORY_ID, - category_name: categoryInfo[0]?.name || "", - category_color: categoryInfo[0]?.color || "", - source_name: null, - source_url: video.videoUrl, - members: memberInfo, - }); - } catch (searchError) { - console.error("Meilisearch 동기화 오류:", searchError.message); - } - - return scheduleId; -} - -/** - * 메인 함수 - */ -async function main() { - console.log("=".repeat(60)); - console.log("X 일정에서 유튜브 영상 추출 시작"); - console.log("=".repeat(60)); - console.log(`관리 중인 채널: ${MANAGED_CHANNEL_IDS.length}개`); - console.log(""); - - // X 카테고리(12) 중 유튜브 링크 포함된 일정 조회 - const [xSchedules] = await pool.query(` - SELECT id, title, description, source_url - FROM schedules - WHERE category_id = 12 - AND (description LIKE '%youtu.be%' - OR description LIKE '%youtube.com/watch%' - OR description LIKE '%youtube.com/shorts%') - `); - - console.log(`처리할 X 일정: ${xSchedules.length}개`); - console.log(""); - - let addedCount = 0; - let skippedManaged = 0; - let skippedDuplicate = 0; - let skippedError = 0; - - for (let i = 0; i < xSchedules.length; i++) { - const schedule = xSchedules[i]; - const videoIds = extractYoutubeVideoIds(schedule.description); - - if (videoIds.length === 0) continue; - - for (const videoId of videoIds) { - process.stdout.write( - `\r[${i + 1}/${xSchedules.length}] 영상 ${videoId} 처리 중...` - ); - - // 영상 정보 조회 - const video = await fetchVideoInfo(videoId); - - if (!video) { - skippedError++; - continue; - } - - // 관리 중인 채널인지 확인 - if (MANAGED_CHANNEL_IDS.includes(video.channelId)) { - skippedManaged++; - continue; - } - - // description에서 멤버 추출 - const memberIds = await extractMemberIds(schedule.description); - - // 일정 생성 - const scheduleId = await createScheduleFromVideo(video, memberIds); - - if (scheduleId) { - addedCount++; - console.log( - `\n ✓ 추가: ${video.title.substring(0, 40)}... (${ - video.channelTitle - })` - ); - } else { - skippedDuplicate++; - } - - // API 할당량 보호를 위한 딜레이 - await new Promise((r) => setTimeout(r, 100)); - } - } - - console.log("\n"); - console.log("=".repeat(60)); - console.log("추출 완료"); - console.log("=".repeat(60)); - console.log(`추가됨: ${addedCount}개`); - console.log(`관리 채널 스킵: ${skippedManaged}개`); - console.log(`중복 스킵: ${skippedDuplicate}개`); - console.log(`오류 스킵: ${skippedError}개`); - - await pool.end(); - process.exit(0); -} - -main().catch((err) => { - console.error("치명적 오류:", err); - process.exit(1); -}); diff --git a/backend/scrape_all.cjs b/backend/scrape_all.cjs deleted file mode 100644 index 22e4f92..0000000 --- a/backend/scrape_all.cjs +++ /dev/null @@ -1,241 +0,0 @@ -const https = require("https"); -const http = require("http"); -const mysql = require("mysql2/promise"); - -// 설정 -const NITTER_URL = "http://nitter:8080"; -const USERNAME = "realfromis_9"; -const DELAY_MS = 1000; // 페이지 간 딜레이 - -// DB 연결 -const dbConfig = { - host: process.env.DB_HOST || "mariadb", - user: process.env.DB_USER || "fromis9_user", - password: process.env.DB_PASSWORD || "fromis9_password", - database: process.env.DB_NAME || "fromis9", -}; - -async function fetchPage(url) { - return new Promise((resolve, reject) => { - const client = url.startsWith("https") ? https : http; - client - .get(url, (res) => { - let data = ""; - res.on("data", (chunk) => (data += chunk)); - res.on("end", () => resolve(data)); - }) - .on("error", reject); - }); -} - -function parseDateTime(timeStr) { - // "Jan 7, 2026 · 12:00 PM UTC" -> MySQL DATETIME - if (!timeStr) return null; - try { - const cleaned = timeStr.replace(" · ", " ").replace(" UTC", ""); - const date = new Date(cleaned + " UTC"); - if (isNaN(date.getTime())) return null; - return date.toISOString().slice(0, 19).replace("T", " "); - } catch (e) { - return null; - } -} - -function extractTweets(html) { - const tweets = []; - const tweetContainers = html.split('class="timeline-item '); - - for (let i = 1; i < tweetContainers.length; i++) { - const container = tweetContainers[i]; - const tweet = {}; - - // 고정 트윗 체크 - tweet.isPinned = - tweetContainers[i - 1].includes("pinned") || container.includes("Pinned"); - - // 리트윗 체크 - tweet.isRetweet = container.includes('class="retweet-header"'); - - // 트윗 ID 추출 - const linkMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/); - tweet.id = linkMatch ? linkMatch[1] : null; - - // 시간 추출 - const timeMatch = container.match( - /]*>]*title="([^"]+)"/ - ); - tweet.time = timeMatch ? parseDateTime(timeMatch[1]) : null; - - // 텍스트 내용 추출 - const contentMatch = container.match( - /
]*>([\s\S]*?)<\/div>/ - ); - if (contentMatch) { - tweet.text = contentMatch[1] - .replace(//g, "\n") - .replace(/]*>([^<]*)<\/a>/g, "$1") - .replace(/<[^>]+>/g, "") - .trim(); - } - - // 이미지 URL 추출 - const imageMatches = container.match(/href="\/pic\/([^"]+)"/g); - tweet.images = []; - if (imageMatches) { - imageMatches.forEach((match) => { - const urlMatch = match.match(/href="\/pic\/([^"]+)"/); - if (urlMatch) { - const decoded = decodeURIComponent(urlMatch[1]); - // 전체 URL로 변환 - tweet.images.push("https://pbs.twimg.com/" + decoded); - } - }); - } - - // 비디오 체크 - tweet.hasVideo = - container.includes("gallery-video") || - container.includes("video-container"); - - // URL 생성 - tweet.url = tweet.id - ? `https://x.com/${USERNAME}/status/${tweet.id}` - : null; - - if (tweet.id) { - tweets.push(tweet); - } - } - - return tweets; -} - -function extractNextCursor(html) { - // show-more 링크에서 cursor 추출 - const cursorMatch = html.match( - /class="show-more"[^>]*>\s* 트윗 없음 (연속 ${consecutiveEmpty}회)`); - if (consecutiveEmpty >= 3) { - console.log("\n연속 3페이지 트윗 없음. 스크래핑 완료."); - break; - } - } else { - consecutiveEmpty = 0; - const saved = await saveTweets(pool, tweets); - totalSaved += saved; - console.log( - ` -> ${tweets.length}개 추출, ${saved}개 저장 (누적: ${totalSaved})` - ); - } - - // 다음 페이지 cursor 추출 - const nextCursor = extractNextCursor(html); - if (!nextCursor) { - console.log("\n다음 페이지 없음. 스크래핑 완료."); - break; - } - - cursor = nextCursor; - pageNum++; - - // 딜레이 - await new Promise((r) => setTimeout(r, DELAY_MS)); - } catch (error) { - console.error(` -> 오류: ${error.message}`); - consecutiveEmpty++; - if (consecutiveEmpty >= 5) { - console.log("\n연속 오류. 스크래핑 중단."); - break; - } - await new Promise((r) => setTimeout(r, DELAY_MS * 3)); - } - } - - console.log("\n" + "=".repeat(60)); - console.log("스크래핑 완료"); - console.log(`총 저장: ${totalSaved}개`); - console.log("=".repeat(60)); - - // 통계 출력 - const [stats] = await pool.query(` - SELECT - COUNT(*) as total, - SUM(is_retweet) as retweets, - SUM(NOT is_retweet) as original, - SUM(has_video) as with_video, - MIN(created_at) as oldest, - MAX(created_at) as newest - FROM x_tweets - `); - console.log("\n[통계]"); - console.log(stats[0]); - - await pool.end(); - process.exit(0); -} - -main().catch((err) => { - console.error("치명적 오류:", err); - process.exit(1); -}); diff --git a/backend/scrape_all.js b/backend/scrape_all.js deleted file mode 100644 index 83fd09f..0000000 --- a/backend/scrape_all.js +++ /dev/null @@ -1,239 +0,0 @@ -const https = require("https"); -const http = require("http"); -const mysql = require("mysql2/promise"); - -// 설정 -const NITTER_URL = "http://nitter:8080"; -const USERNAME = "realfromis_9"; -const DELAY_MS = 1000; // 페이지 간 딜레이 - -// DB 연결 -const dbConfig = { - host: process.env.DB_HOST || "mariadb", - user: process.env.DB_USER || "fromis9_user", - password: process.env.DB_PASSWORD || "fromis9_password", - database: process.env.DB_NAME || "fromis9", -}; - -async function fetchPage(url) { - return new Promise((resolve, reject) => { - const client = url.startsWith("https") ? https : http; - client - .get(url, (res) => { - let data = ""; - res.on("data", (chunk) => (data += chunk)); - res.on("end", () => resolve(data)); - }) - .on("error", reject); - }); -} - -function parseDateTime(timeStr) { - // "Jan 7, 2026 · 12:00 PM UTC" -> MySQL DATETIME - if (!timeStr) return null; - try { - const cleaned = timeStr.replace(" · ", " ").replace(" UTC", ""); - const date = new Date(cleaned + " UTC"); - if (isNaN(date.getTime())) return null; - return date.toISOString().slice(0, 19).replace("T", " "); - } catch (e) { - return null; - } -} - -function extractTweets(html) { - const tweets = []; - const tweetContainers = html.split('class="timeline-item '); - - for (let i = 1; i < tweetContainers.length; i++) { - const container = tweetContainers[i]; - const tweet = {}; - - // 고정 트윗 체크 - tweet.isPinned = - tweetContainers[i - 1].includes("pinned") || container.includes("Pinned"); - - // 리트윗 체크 - tweet.isRetweet = container.includes('class="retweet-header"'); - - // 트윗 ID 추출 - const linkMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/); - tweet.id = linkMatch ? linkMatch[1] : null; - - // 시간 추출 - const timeMatch = container.match( - /]*>]*title="([^"]+)"/ - ); - tweet.time = timeMatch ? parseDateTime(timeMatch[1]) : null; - - // 텍스트 내용 추출 - const contentMatch = container.match( - /
]*>([\s\S]*?)<\/div>/ - ); - if (contentMatch) { - tweet.text = contentMatch[1] - .replace(//g, "\n") - .replace(/]*>([^<]*)<\/a>/g, "$1") - .replace(/<[^>]+>/g, "") - .trim(); - } - - // 이미지 URL 추출 - const imageMatches = container.match(/href="\/pic\/([^"]+)"/g); - tweet.images = []; - if (imageMatches) { - imageMatches.forEach((match) => { - const urlMatch = match.match(/href="\/pic\/([^"]+)"/); - if (urlMatch) { - const decoded = decodeURIComponent(urlMatch[1]); - // 전체 URL로 변환 - tweet.images.push("https://pbs.twimg.com/" + decoded); - } - }); - } - - // 비디오 체크 - tweet.hasVideo = - container.includes("gallery-video") || - container.includes("video-container"); - - // URL 생성 - tweet.url = tweet.id - ? `https://x.com/${USERNAME}/status/${tweet.id}` - : null; - - if (tweet.id) { - tweets.push(tweet); - } - } - - return tweets; -} - -function extractNextCursor(html) { - // Load more 링크에서 cursor 추출 - const cursorMatch = html.match(/href="\/[^?]+\?cursor=([^"]+)"/); - return cursorMatch ? cursorMatch[1] : null; -} - -async function saveTweets(pool, tweets) { - let saved = 0; - for (const tweet of tweets) { - try { - await pool.query( - ` - INSERT IGNORE INTO x_tweets (id, username, text, created_at, is_retweet, is_pinned, images, has_video, url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - [ - tweet.id, - USERNAME, - tweet.text, - tweet.time, - tweet.isRetweet, - tweet.isPinned, - JSON.stringify(tweet.images), - tweet.hasVideo, - tweet.url, - ] - ); - saved++; - } catch (e) { - console.error(`저장 오류 (ID: ${tweet.id}):`, e.message); - } - } - return saved; -} - -async function main() { - console.log("=".repeat(60)); - console.log("X 트윗 전체 스크래핑 시작"); - console.log("=".repeat(60)); - console.log(`대상: @${USERNAME}`); - console.log(`Nitter: ${NITTER_URL}`); - console.log(""); - - const pool = await mysql.createPool(dbConfig); - - let cursor = null; - let pageNum = 1; - let totalSaved = 0; - let consecutiveEmpty = 0; - - while (true) { - const url = cursor - ? `${NITTER_URL}/${USERNAME}?cursor=${cursor}` - : `${NITTER_URL}/${USERNAME}`; - - console.log(`[페이지 ${pageNum}] 스크래핑 중...`); - - try { - const html = await fetchPage(url); - const tweets = extractTweets(html); - - if (tweets.length === 0) { - consecutiveEmpty++; - console.log(` -> 트윗 없음 (연속 ${consecutiveEmpty}회)`); - if (consecutiveEmpty >= 3) { - console.log("\n연속 3페이지 트윗 없음. 스크래핑 완료."); - break; - } - } else { - consecutiveEmpty = 0; - const saved = await saveTweets(pool, tweets); - totalSaved += saved; - console.log( - ` -> ${tweets.length}개 추출, ${saved}개 저장 (누적: ${totalSaved})` - ); - } - - // 다음 페이지 cursor 추출 - const nextCursor = extractNextCursor(html); - if (!nextCursor) { - console.log("\n다음 페이지 없음. 스크래핑 완료."); - break; - } - - cursor = nextCursor; - pageNum++; - - // 딜레이 - await new Promise((r) => setTimeout(r, DELAY_MS)); - } catch (error) { - console.error(` -> 오류: ${error.message}`); - consecutiveEmpty++; - if (consecutiveEmpty >= 5) { - console.log("\n연속 오류. 스크래핑 중단."); - break; - } - await new Promise((r) => setTimeout(r, DELAY_MS * 3)); - } - } - - console.log("\n" + "=".repeat(60)); - console.log("스크래핑 완료"); - console.log(`총 저장: ${totalSaved}개`); - console.log("=".repeat(60)); - - // 통계 출력 - const [stats] = await pool.query(` - SELECT - COUNT(*) as total, - SUM(is_retweet) as retweets, - SUM(NOT is_retweet) as original, - SUM(has_video) as with_video, - MIN(created_at) as oldest, - MAX(created_at) as newest - FROM x_tweets - `); - console.log("\n[통계]"); - console.log(stats[0]); - - await pool.end(); - process.exit(0); -} - -main().catch((err) => { - console.error("치명적 오류:", err); - process.exit(1); -}); diff --git a/backend/scrape_log.txt b/backend/scrape_log.txt deleted file mode 100644 index f6c495f..0000000 --- a/backend/scrape_log.txt +++ /dev/null @@ -1,111 +0,0 @@ -============================================================ -X 트윗 전체 스크래핑 시작 -============================================================ -대상: @realfromis_9 -Nitter: http://nitter:8080 - -[페이지 1] 스크래핑 중... - -> 21개 추출, 21개 저장 (누적: 21) -[페이지 2] 스크래핑 중... - -> 19개 추출, 19개 저장 (누적: 40) -[페이지 3] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 60) -[페이지 4] 스크래핑 중... - -> 19개 추출, 19개 저장 (누적: 79) -[페이지 5] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 99) -[페이지 6] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 119) -[페이지 7] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 139) -[페이지 8] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 159) -[페이지 9] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 179) -[페이지 10] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 199) -[페이지 11] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 219) -[페이지 12] 스크래핑 중... - -> 18개 추출, 18개 저장 (누적: 237) -[페이지 13] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 257) -[페이지 14] 스크래핑 중... - -> 19개 추출, 19개 저장 (누적: 276) -[페이지 15] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 296) -[페이지 16] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 316) -[페이지 17] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 336) -[페이지 18] 스크래핑 중... - -> 19개 추출, 19개 저장 (누적: 355) -[페이지 19] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 375) -[페이지 20] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 395) -[페이지 21] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 415) -[페이지 22] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 435) -[페이지 23] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 455) -[페이지 24] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 475) -[페이지 25] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 495) -[페이지 26] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 515) -[페이지 27] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 535) -[페이지 28] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 555) -[페이지 29] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 575) -[페이지 30] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 595) -[페이지 31] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 615) -[페이지 32] 스크래핑 중... - -> 19개 추출, 19개 저장 (누적: 634) -[페이지 33] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 654) -[페이지 34] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 674) -[페이지 35] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 694) -[페이지 36] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 714) -[페이지 37] 스크래핑 중... - -> 19개 추출, 19개 저장 (누적: 733) -[페이지 38] 스크래핑 중... - -> 18개 추출, 18개 저장 (누적: 751) -[페이지 39] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 771) -[페이지 40] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 791) -[페이지 41] 스크래핑 중... - -> 20개 추출, 20개 저장 (누적: 811) -[페이지 42] 스크래핑 중... - -> 19개 추출, 19개 저장 (누적: 830) -[페이지 43] 스크래핑 중... - -> 10개 추출, 10개 저장 (누적: 840) -[페이지 44] 스크래핑 중... - -> 트윗 없음 (연속 1회) - -다음 페이지 없음. 스크래핑 완료. - -============================================================ -스크래핑 완료 -총 저장: 840개 -============================================================ - -[통계] -{ - total: 840, - retweets: '244', - original: '596', - with_video: '58', - oldest: 2025-06-16T12:01:00.000Z, - newest: 2026-01-07T12:00:00.000Z -} diff --git a/backend/scrape_search.cjs b/backend/scrape_search.cjs deleted file mode 100644 index 8db3e53..0000000 --- a/backend/scrape_search.cjs +++ /dev/null @@ -1,229 +0,0 @@ -const https = require("https"); -const http = require("http"); -const mysql = require("mysql2/promise"); - -// 설정 -const NITTER_URL = "http://nitter:8080"; -const USERNAME = "realfromis_9"; -const DELAY_MS = 1500; - -// 검색 기간 (X 계정 이관일 ~ 기존 스크래핑 시작점) -const SEARCH_SINCE = "2025-04-24"; -const SEARCH_UNTIL = "2025-06-16"; - -// DB 연결 -const dbConfig = { - host: process.env.DB_HOST || "mariadb", - user: process.env.DB_USER || "fromis9_user", - password: process.env.DB_PASSWORD || "fromis9_password", - database: process.env.DB_NAME || "fromis9", -}; - -async function fetchPage(url) { - return new Promise((resolve, reject) => { - const client = url.startsWith("https") ? https : http; - client - .get(url, (res) => { - let data = ""; - res.on("data", (chunk) => (data += chunk)); - res.on("end", () => resolve(data)); - }) - .on("error", reject); - }); -} - -function parseDateTime(timeStr) { - if (!timeStr) return null; - try { - const cleaned = timeStr.replace(" · ", " ").replace(" UTC", ""); - const date = new Date(cleaned + " UTC"); - if (isNaN(date.getTime())) return null; - return date.toISOString().slice(0, 19).replace("T", " "); - } catch (e) { - return null; - } -} - -function extractSearchTweets(html) { - const tweets = []; - const tweetContainers = html.split('class="timeline-item '); - - for (let i = 1; i < tweetContainers.length; i++) { - const container = tweetContainers[i]; - const tweet = {}; - - tweet.isPinned = false; - tweet.isRetweet = container.includes('class="retweet-header"'); - - const linkMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/); - tweet.id = linkMatch ? linkMatch[1] : null; - - const timeMatch = container.match( - /]*>]*title="([^"]+)"/ - ); - tweet.time = timeMatch ? parseDateTime(timeMatch[1]) : null; - - const contentMatch = container.match( - /
]*>([\s\S]*?)<\/div>/ - ); - if (contentMatch) { - tweet.text = contentMatch[1] - .replace(//g, "\n") - .replace(/]*>([^<]*)<\/a>/g, "$1") - .replace(/<[^>]+>/g, "") - .trim(); - } - - const imageMatches = container.match(/href="\/pic\/([^"]+)"/g); - tweet.images = []; - if (imageMatches) { - imageMatches.forEach((match) => { - const urlMatch = match.match(/href="\/pic\/([^"]+)"/); - if (urlMatch) { - const decoded = decodeURIComponent(urlMatch[1]); - tweet.images.push("https://pbs.twimg.com/" + decoded); - } - }); - } - - tweet.hasVideo = - container.includes("gallery-video") || - container.includes("video-container"); - - tweet.url = tweet.id - ? `https://x.com/${USERNAME}/status/${tweet.id}` - : null; - - if (tweet.id) { - tweets.push(tweet); - } - } - - return tweets; -} - -function extractNextCursor(html) { - const cursorMatch = html.match( - /class="show-more"[^>]*>\s* 0) saved++; - } catch (e) { - console.error(`저장 오류 (ID: ${tweet.id}):`, e.message); - } - } - return saved; -} - -async function main() { - console.log("=".repeat(60)); - console.log("X 트윗 검색 스크래핑 (누락 기간)"); - console.log("=".repeat(60)); - console.log(`대상: @${USERNAME}`); - console.log(`기간: ${SEARCH_SINCE} ~ ${SEARCH_UNTIL}`); - console.log(""); - - const pool = await mysql.createPool(dbConfig); - - const searchQuery = encodeURIComponent( - `from:${USERNAME} since:${SEARCH_SINCE} until:${SEARCH_UNTIL}` - ); - let cursor = null; - let pageNum = 1; - let totalSaved = 0; - let consecutiveEmpty = 0; - - while (true) { - const url = cursor - ? `${NITTER_URL}/search?f=tweets&q=${searchQuery}&cursor=${cursor}` - : `${NITTER_URL}/search?f=tweets&q=${searchQuery}`; - - console.log(`[페이지 ${pageNum}] 검색 중...`); - - try { - const html = await fetchPage(url); - const tweets = extractSearchTweets(html); - - if (tweets.length === 0) { - consecutiveEmpty++; - console.log(` -> 트윗 없음 (연속 ${consecutiveEmpty}회)`); - if (consecutiveEmpty >= 3) { - console.log("\n연속 3페이지 트윗 없음. 스크래핑 완료."); - break; - } - } else { - consecutiveEmpty = 0; - const saved = await saveTweets(pool, tweets); - totalSaved += saved; - console.log( - ` -> ${tweets.length}개 추출, ${saved}개 저장 (누적: ${totalSaved})` - ); - } - - const nextCursor = extractNextCursor(html); - if (!nextCursor) { - console.log("\n다음 페이지 없음. 스크래핑 완료."); - break; - } - - cursor = nextCursor; - pageNum++; - - await new Promise((r) => setTimeout(r, DELAY_MS)); - } catch (error) { - console.error(` -> 오류: ${error.message}`); - consecutiveEmpty++; - if (consecutiveEmpty >= 5) { - console.log("\n연속 오류. 스크래핑 중단."); - break; - } - await new Promise((r) => setTimeout(r, DELAY_MS * 3)); - } - } - - console.log("\n" + "=".repeat(60)); - console.log("검색 스크래핑 완료"); - console.log(`추가 저장: ${totalSaved}개`); - console.log("=".repeat(60)); - - const [stats] = await pool.query(` - SELECT - COUNT(*) as total, - SUM(is_retweet) as retweets, - SUM(NOT is_retweet) as original, - MIN(created_at) as oldest, - MAX(created_at) as newest - FROM x_tweets - `); - console.log("\n[전체 통계]"); - console.log(stats[0]); - - await pool.end(); - process.exit(0); -} - -main().catch((err) => { - console.error("치명적 오류:", err); - process.exit(1); -}); diff --git a/backend/scrape_search_log.txt b/backend/scrape_search_log.txt deleted file mode 100644 index c49dfd4..0000000 --- a/backend/scrape_search_log.txt +++ /dev/null @@ -1,34 +0,0 @@ -============================================================ -X 트윗 검색 스크래핑 (누락 기간) -============================================================ -대상: @realfromis_9 -기간: 2025-04-24 ~ 2025-06-16 - -[페이지 1] 검색 중... - -> 20개 추출, 20개 저장 (누적: 20) -[페이지 2] 검색 중... - -> 20개 추출, 20개 저장 (누적: 40) -[페이지 3] 검색 중... - -> 20개 추출, 20개 저장 (누적: 60) -[페이지 4] 검색 중... - -> 20개 추출, 20개 저장 (누적: 80) -[페이지 5] 검색 중... - -> 14개 추출, 14개 저장 (누적: 94) -[페이지 6] 검색 중... - -> 트윗 없음 (연속 1회) - -다음 페이지 없음. 스크래핑 완료. - -============================================================ -검색 스크래핑 완료 -추가 저장: 94개 -============================================================ - -[전체 통계] -{ - total: 934, - retweets: '244', - original: '690', - oldest: 2025-04-24T12:00:00.000Z, - newest: 2026-01-07T12:00:00.000Z -}