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:
parent
59e5a1d47b
commit
0ee587ad08
6 changed files with 0 additions and 1171 deletions
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue