import { parseNitterDateTime } from '../../utils/date.js';
const FETCH_TIMEOUT = 10000; // 10초
/**
* 타임아웃이 적용된 fetch
*/
async function fetchWithTimeout(url, timeout = FETCH_TIMEOUT) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res;
} catch (err) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
throw new Error('요청 타임아웃');
}
throw err;
}
}
/**
* 트윗 텍스트에서 첫 문단 추출 (title용)
*/
export function extractTitle(text) {
if (!text) return '';
const paragraphs = text.split(/\n\n+/);
return paragraphs[0]?.trim() || '';
}
/**
* HTML에서 이미지 URL 추출
*/
export function extractImageUrls(html) {
const urls = [];
const regex = /href="\/pic\/(orig\/)?media%2F([^"]+)"/g;
let match;
while ((match = regex.exec(html)) !== null) {
const mediaPath = decodeURIComponent(match[2]);
const cleanPath = mediaPath.split('%3F')[0].split('?')[0];
urls.push(`https://pbs.twimg.com/media/${cleanPath}`);
}
return [...new Set(urls)];
}
/**
* HTML에서 영상/GIF 썸네일 URL 추출
* Nitter는 영상 파일을 제공하지 않고 썸네일만 노출 (amplify_video_thumb,
* ext_tw_video_thumb, tweet_video_thumb). 재생은 원본 트윗으로 이동.
*/
export function extractVideoThumbnails(html) {
const urls = [];
const regex = /
]*title="([^"]+)"/);
if (nameMatch) {
profile.displayName = nameMatch[1].trim();
}
const avatarMatch = html.match(/class="profile-card-avatar"[^>]*>[\s\S]*?
]*src="([^"]+)"/);
if (avatarMatch) {
let url = avatarMatch[1];
const encodedMatch = url.match(/\/pic\/(.+)/);
if (encodedMatch) {
url = decodeURIComponent(encodedMatch[1]);
}
profile.avatarUrl = url;
}
return profile;
}
/**
* 트윗 HTML 컨텐츠에서 텍스트 추출 (링크는 원본 URL 사용)
*/
function extractTextFromHtml(html) {
return html
.replace(/
/g, '\n')
// 태그: href에서 원본 URL 추출 (외부 링크만)
.replace(/]*href="([^"]*)"[^>]*>([^<]*)<\/a>/g, (match, href, text) => {
// t.co 링크: Nitter가 프록시한 URL을 원본 t.co URL로 변환
const tcoMatch = href.match(/\/t\.co\/([^\s"?]+)/);
if (tcoMatch) {
return `https://t.co/${tcoMatch[1]}`;
}
// Nitter 내부 링크 (/search, /hashtag 등)는 표시 텍스트 사용
if (href.startsWith('/')) {
return text;
}
// 외부 링크는 href의 원본 URL 사용
return href;
})
.replace(/<[^>]+>/g, '')
.trim();
}
/**
* HTML에서 트윗 목록 파싱
* @param {string} html - HTML 문자열
* @param {string} username - 사용자명
* @param {object} options - 옵션
* @param {boolean} options.includeRetweets - 리트윗 포함 여부 (기본: false)
*/
export function parseTweets(html, username, options = {}) {
const { includeRetweets = false } = options;
const tweets = [];
const containers = html.split('class="timeline-item ');
for (let i = 1; i < containers.length; i++) {
const container = containers[i];
// 고정 트윗 제외
const isPinned = container.includes('class="pinned"');
if (isPinned) continue;
// 리트윗 필터링 (옵션에 따라)
const isRetweet = container.includes('class="retweet-header"');
if (isRetweet && !includeRetweets) continue;
// 리트윗인 경우 원본 작성자 추출 (data-username 또는 tweet-header에서)
let originalUsername = null;
if (isRetweet) {
const dataUserMatch = containers[i - 1]?.match(/data-username="([^"]+)"/) ||
container.match(/data-username="([^"]+)"/);
if (dataUserMatch) {
originalUsername = dataUserMatch[1];
} else {
// tweet-header의 username 링크에서 추출
const headerUserMatch = container.match(/class="username"[^>]*href="\/([^"]+)"/);
if (headerUserMatch) {
originalUsername = headerUserMatch[1];
}
}
}
// 트윗 ID
const idMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/);
if (!idMatch) continue;
const id = idMatch[1];
// 시간
const timeMatch = container.match(/