refactor: API 및 페이지 폴더 구조 정리 (2/3)
- api/schedules, albums, members → api/public/로 이동 - pages/pc/*.jsx → pages/pc/public/로 이동 - pages/mobile/*.jsx → pages/mobile/public/로 이동 - App.jsx 라우터 경로 수정 - 모든 public 페이지의 import 경로 수정
This commit is contained in:
parent
9886048a4c
commit
e994aa08ca
21 changed files with 870 additions and 16 deletions
241
backend/scrape_all.cjs
Normal file
241
backend/scrape_all.cjs
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
239
backend/scrape_all.js
Normal file
239
backend/scrape_all.js
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
111
backend/scrape_log.txt
Normal file
111
backend/scrape_log.txt
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
============================================================
|
||||||
|
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
|
||||||
|
}
|
||||||
229
backend/scrape_search.cjs
Normal file
229
backend/scrape_search.cjs
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
34
backend/scrape_search_log.txt
Normal file
34
backend/scrape_search_log.txt
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
============================================================
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -6,20 +6,20 @@ import { BrowserView, MobileView } from 'react-device-detect';
|
||||||
import ScrollToTop from './components/ScrollToTop';
|
import ScrollToTop from './components/ScrollToTop';
|
||||||
|
|
||||||
// PC 페이지
|
// PC 페이지
|
||||||
import PCHome from './pages/pc/Home';
|
import PCHome from './pages/pc/public/Home';
|
||||||
import PCMembers from './pages/pc/Members';
|
import PCMembers from './pages/pc/public/Members';
|
||||||
import PCAlbum from './pages/pc/Album';
|
import PCAlbum from './pages/pc/public/Album';
|
||||||
import PCAlbumDetail from './pages/pc/AlbumDetail';
|
import PCAlbumDetail from './pages/pc/public/AlbumDetail';
|
||||||
import PCAlbumGallery from './pages/pc/AlbumGallery';
|
import PCAlbumGallery from './pages/pc/public/AlbumGallery';
|
||||||
import PCSchedule from './pages/pc/Schedule';
|
import PCSchedule from './pages/pc/public/Schedule';
|
||||||
|
|
||||||
// 모바일 페이지
|
// 모바일 페이지
|
||||||
import MobileHome from './pages/mobile/Home';
|
import MobileHome from './pages/mobile/public/Home';
|
||||||
import MobileMembers from './pages/mobile/Members';
|
import MobileMembers from './pages/mobile/public/Members';
|
||||||
import MobileAlbum from './pages/mobile/Album';
|
import MobileAlbum from './pages/mobile/public/Album';
|
||||||
import MobileAlbumDetail from './pages/mobile/AlbumDetail';
|
import MobileAlbumDetail from './pages/mobile/public/AlbumDetail';
|
||||||
import MobileAlbumGallery from './pages/mobile/AlbumGallery';
|
import MobileAlbumGallery from './pages/mobile/public/AlbumGallery';
|
||||||
import MobileSchedule from './pages/mobile/Schedule';
|
import MobileSchedule from './pages/mobile/public/Schedule';
|
||||||
|
|
||||||
// 관리자 페이지
|
// 관리자 페이지
|
||||||
import AdminLogin from './pages/pc/admin/AdminLogin';
|
import AdminLogin from './pages/pc/admin/AdminLogin';
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { motion } from 'framer-motion';
|
||||||
import { ChevronRight, Clock, Tag } from 'lucide-react';
|
import { ChevronRight, Clock, Tag } from 'lucide-react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getTodayKST } from '../../utils/date';
|
import { getTodayKST } from '../../../utils/date';
|
||||||
|
|
||||||
// 모바일 홈 페이지
|
// 모바일 홈 페이지
|
||||||
function MobileHome() {
|
function MobileHome() {
|
||||||
|
|
@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Calendar, ArrowRight, Clock, Link2, Tag } from 'lucide-react';
|
import { Calendar, ArrowRight, Clock, Link2, Tag } from 'lucide-react';
|
||||||
import { getTodayKST } from '../../utils/date';
|
import { getTodayKST } from '../../../utils/date';
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const [members, setMembers] = useState([]);
|
const [members, setMembers] = useState([]);
|
||||||
|
|
@ -4,8 +4,8 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Clock, ChevronLeft, ChevronRight, ChevronDown, Tag, Search, ArrowLeft, Link2 } from 'lucide-react';
|
import { Clock, ChevronLeft, ChevronRight, ChevronDown, Tag, Search, ArrowLeft, Link2 } from 'lucide-react';
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
import { getTodayKST } from '../../utils/date';
|
import { getTodayKST } from '../../../../utils/date';
|
||||||
import { getSchedules, getCategories, searchSchedules as searchSchedulesApi } from '../../api/schedules';
|
import { getSchedules, getCategories, searchSchedules as searchSchedulesApi } from '../../../../api/public/schedules';
|
||||||
|
|
||||||
function Schedule() {
|
function Schedule() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
Loading…
Add table
Reference in a new issue