chore: 불필요한 스크립트 파일 삭제

- scrape_all.cjs, scrape_all.js, scrape_search.cjs 삭제 (미사용)
- scrape_log.txt, scrape_search_log.txt 삭제 (로그 파일)
- extract_youtube_from_x.js 삭제 (일회성 스크립트)
This commit is contained in:
caadiq 2026-01-10 17:22:45 +09:00
parent 59e5a1d47b
commit 0ee587ad08
6 changed files with 0 additions and 1171 deletions

View file

@ -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);
});

View file

@ -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(
/<span class="tweet-date"[^>]*><a[^>]*title="([^"]+)"/
);
tweet.time = timeMatch ? parseDateTime(timeMatch[1]) : null;
// 텍스트 내용 추출
const contentMatch = container.match(
/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/
);
if (contentMatch) {
tweet.text = contentMatch[1]
.replace(/<br\s*\/?>/g, "\n")
.replace(/<a[^>]*>([^<]*)<\/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*<a 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);
});

View file

@ -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(
/<span class="tweet-date"[^>]*><a[^>]*title="([^"]+)"/
);
tweet.time = timeMatch ? parseDateTime(timeMatch[1]) : null;
// 텍스트 내용 추출
const contentMatch = container.match(
/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/
);
if (contentMatch) {
tweet.text = contentMatch[1]
.replace(/<br\s*\/?>/g, "\n")
.replace(/<a[^>]*>([^<]*)<\/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);
});

View file

@ -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
}

View file

@ -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(
/<span class="tweet-date"[^>]*><a[^>]*title="([^"]+)"/
);
tweet.time = timeMatch ? parseDateTime(timeMatch[1]) : null;
const contentMatch = container.match(
/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/
);
if (contentMatch) {
tweet.text = contentMatch[1]
.replace(/<br\s*\/?>/g, "\n")
.replace(/<a[^>]*>([^<]*)<\/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*<a href="[^"]*cursor=([^"&]+)/
);
return cursorMatch ? cursorMatch[1] : null;
}
async function saveTweets(pool, tweets) {
let saved = 0;
for (const tweet of tweets) {
try {
const [result] = 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,
]
);
if (result.affectedRows > 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);
});

View file

@ -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
}