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