/g, '\n') .replace(/]*>([^<]*)<\/a>/g, '$1') .replace(/<[^>]+>/g, '') .trim(); } // 이미지 const imageUrls = extractImageUrls(container); // 프로필 정보 const profile = extractProfile(html); return { id: postId, time, text, imageUrls, url: `https://x.com/${username}/status/${postId}`, profile, }; } /** * Nitter에서 트윗 수집 (첫 페이지만) */ export async function fetchTweets(nitterUrl, username) { const url = `${nitterUrl}/${username}`; const res = await fetch(url); const html = await res.text(); // 프로필 정보 const profile = extractProfile(html); // 트윗 파싱 const tweets = parseTweets(html, username); return { tweets, profile }; } /** * Nitter에서 전체 트윗 수집 (페이지네이션) */ export async function fetchAllTweets(nitterUrl, username, log) { const allTweets = []; let cursor = null; let pageNum = 1; let emptyCount = 0; while (true) { const url = cursor ? `${nitterUrl}/${username}?cursor=${cursor}` : `${nitterUrl}/${username}`; log?.info(`[페이지 ${pageNum}] 스크래핑 중...`); try { const res = await fetch(url); const html = await res.text(); const tweets = parseTweets(html, username); if (tweets.length === 0) { emptyCount++; if (emptyCount >= 3) break; } else { emptyCount = 0; allTweets.push(...tweets); log?.info(` -> ${tweets.length}개 추출 (누적: ${allTweets.length})`); } // 다음 페이지 cursor const cursorMatch = html.match(/class="show-more"[^>]*>\s* setTimeout(r, 1000)); } catch (err) { log?.error(` -> 오류: ${err.message}`); emptyCount++; if (emptyCount >= 5) break; await new Promise(r => setTimeout(r, 3000)); } } return allTweets; }