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:
parent
4cfa4ffc00
commit
d3d3c9cf75
5 changed files with 88 additions and 3 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue