feat(x-schedule): 트윗 네이티브 영상 썸네일 표시

Nitter는 영상 파일을 제공하지 않으므로 썸네일만 추출해 표시하고,
재생 시 원본 트윗으로 이동.

- scraper: extractVideoThumbnails 추가 (amplify_video_thumb 등)
- schedule_x.video_thumbnails 컬럼 + saveTweet 저장
- getScheduleDetail 응답에 videoThumbnails 포함
- PC/모바일 X 상세: 썸네일 + 재생버튼 + 'X에서 재생' 배지

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-05-31 19:02:29 +09:00
parent 4cfa4ffc00
commit d3d3c9cf75
5 changed files with 88 additions and 3 deletions

View file

@ -214,6 +214,7 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
sx.username as x_username, sx.username as x_username,
sx.content as x_content, sx.content as x_content,
sx.image_urls as x_image_urls, sx.image_urls as x_image_urls,
sx.video_thumbnails as x_video_thumbnails,
sv.broadcaster as variety_broadcaster, sv.broadcaster as variety_broadcaster,
sv.replay_url as variety_replay_url, sv.replay_url as variety_replay_url,
svi.medium_url as variety_thumbnail_url, svi.medium_url as variety_thumbnail_url,
@ -297,6 +298,7 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
result.username = username; result.username = username;
result.content = s.x_content || null; result.content = s.x_content || null;
result.imageUrls = s.x_image_urls ? JSON.parse(s.x_image_urls) : []; result.imageUrls = s.x_image_urls ? JSON.parse(s.x_image_urls) : [];
result.videoThumbnails = s.x_video_thumbnails ? JSON.parse(s.x_video_thumbnails) : [];
result.postUrl = `https://x.com/${username}/status/${s.x_post_id}`; result.postUrl = `https://x.com/${username}/status/${s.x_post_id}`;
if (getXProfile) { if (getXProfile) {

View file

@ -93,13 +93,14 @@ async function xBotPlugin(fastify, opts) {
// schedule_x 테이블에 저장 // schedule_x 테이블에 저장
await connection.query( await connection.query(
'INSERT INTO schedule_x (schedule_id, post_id, username, content, image_urls) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO schedule_x (schedule_id, post_id, username, content, image_urls, video_thumbnails) VALUES (?, ?, ?, ?, ?, ?)',
[ [
scheduleId, scheduleId,
tweet.id, tweet.id,
tweetUsername, tweetUsername,
tweet.text, tweet.text,
tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null, tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null,
tweet.videoThumbnails?.length > 0 ? JSON.stringify(tweet.videoThumbnails) : null,
] ]
); );

View file

@ -51,6 +51,22 @@ export function extractImageUrls(html) {
return [...new Set(urls)]; 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 = /<img src="(\/pic\/[^"]*video_thumb[^"]*)"/g;
let match;
while ((match = regex.exec(html)) !== null) {
const path = decodeURIComponent(match[1].replace(/^\/pic\//, '')).split('?')[0];
urls.push(`https://pbs.twimg.com/${path}`);
}
return [...new Set(urls)];
}
/** /**
* 텍스트에서 유튜브 videoId 추출 * 텍스트에서 유튜브 videoId 추출
*/ */
@ -184,14 +200,16 @@ export function parseTweets(html, username, options = {}) {
text = extractTextFromHtml(contentMatch[1]); text = extractTextFromHtml(contentMatch[1]);
} }
// 이미지 // 이미지 / 영상 썸네일
const imageUrls = extractImageUrls(container); const imageUrls = extractImageUrls(container);
const videoThumbnails = extractVideoThumbnails(container);
tweets.push({ tweets.push({
id, id,
time, time,
text, text,
imageUrls, imageUrls,
videoThumbnails,
isRetweet, isRetweet,
originalUsername, originalUsername,
// 긴 트윗(280자 초과)이 …로 잘렸는지 여부 (hydrate 대상 판별용) // 긴 트윗(280자 초과)이 …로 잘렸는지 여부 (hydrate 대상 판별용)
@ -232,8 +250,9 @@ export async function fetchSingleTweet(nitterUrl, username, postId) {
text = extractTextFromHtml(contentMatch[1]); text = extractTextFromHtml(contentMatch[1]);
} }
// 이미지 // 이미지 / 영상 썸네일
const imageUrls = extractImageUrls(container); const imageUrls = extractImageUrls(container);
const videoThumbnails = extractVideoThumbnails(container);
// 프로필 정보 // 프로필 정보
const profile = extractProfile(html); const profile = extractProfile(html);
@ -243,6 +262,7 @@ export async function fetchSingleTweet(nitterUrl, username, postId) {
time, time,
text, text,
imageUrls, imageUrls,
videoThumbnails,
url: `https://x.com/${username}/status/${postId}`, url: `https://x.com/${username}/status/${postId}`,
profile, profile,
}; };

View file

@ -387,6 +387,37 @@ function MobileXSection({ schedule }) {
</div> </div>
)} )}
{/* 영상 썸네일 (재생 버튼 → 원본 트윗으로 이동) */}
{schedule.videoThumbnails?.length > 0 && (
<div className="px-4 pb-3 space-y-2">
{schedule.videoThumbnails.map((url, i) => (
<a
key={i}
href={schedule.postUrl}
target="_blank"
rel="noopener noreferrer"
className="relative block rounded-xl overflow-hidden border border-gray-100"
>
<img src={url} alt="" className="w-full object-cover" />
<div className="absolute inset-0 flex items-center justify-center bg-black/10">
<div className="w-14 h-14 rounded-full bg-black/60 flex items-center justify-center">
<svg className="w-6 h-6 text-white ml-0.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
{/* 외부(X) 재생 표시 배지 */}
<div className="absolute bottom-2.5 right-2.5 flex items-center gap-1 px-2 py-1 rounded-full bg-black/70 text-white text-[11px] font-medium backdrop-blur-sm">
<svg className="w-2.5 h-2.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
X에서 재생
</div>
</a>
))}
</div>
)}
{/* 날짜/시간 */} {/* 날짜/시간 */}
<div className="px-4 py-3 border-t border-gray-100"> <div className="px-4 py-3 border-t border-gray-100">
<span className="text-gray-500 text-sm">{formatXDateTimeWithTime(schedule.date, schedule.time)}</span> <span className="text-gray-500 text-sm">{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>

View file

@ -166,6 +166,37 @@ function XSection({ schedule }) {
</div> </div>
)} )}
{/* 영상 썸네일 (재생 버튼 → 원본 트윗으로 이동) */}
{schedule.videoThumbnails?.length > 0 && (
<div className="px-5 pb-3 space-y-2">
{schedule.videoThumbnails.map((url, i) => (
<a
key={i}
href={schedule.postUrl}
target="_blank"
rel="noopener noreferrer"
className="relative block rounded-2xl overflow-hidden border border-gray-100 group"
>
<img src={url} alt="" className="w-full object-cover" />
<div className="absolute inset-0 flex items-center justify-center bg-black/10 group-hover:bg-black/20 transition-colors">
<div className="w-16 h-16 rounded-full bg-black/60 group-hover:bg-black/70 flex items-center justify-center transition-colors">
<svg className="w-7 h-7 text-white ml-1" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
{/* 외부(X) 재생 표시 배지 */}
<div className="absolute bottom-3 right-3 flex items-center gap-1.5 px-2.5 py-1.5 rounded-full bg-black/70 text-white text-xs font-medium backdrop-blur-sm">
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
X에서 재생
</div>
</a>
))}
</div>
)}
{/* 날짜/시간 */} {/* 날짜/시간 */}
<div className="px-5 py-4 border-t border-gray-100"> <div className="px-5 py-4 border-t border-gray-100">
<span className="text-gray-500 text-[15px]">{formatXDateTimeWithTime(schedule.date, schedule.time)}</span> <span className="text-gray-500 text-[15px]">{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>