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.content as x_content,
|
||||
sx.image_urls as x_image_urls,
|
||||
sx.video_thumbnails as x_video_thumbnails,
|
||||
sv.broadcaster as variety_broadcaster,
|
||||
sv.replay_url as variety_replay_url,
|
||||
svi.medium_url as variety_thumbnail_url,
|
||||
|
|
@ -297,6 +298,7 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
|
|||
result.username = username;
|
||||
result.content = s.x_content || null;
|
||||
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}`;
|
||||
|
||||
if (getXProfile) {
|
||||
|
|
|
|||
|
|
@ -93,13 +93,14 @@ async function xBotPlugin(fastify, opts) {
|
|||
|
||||
// schedule_x 테이블에 저장
|
||||
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,
|
||||
tweet.id,
|
||||
tweetUsername,
|
||||
tweet.text,
|
||||
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)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 추출
|
||||
*/
|
||||
|
|
@ -184,14 +200,16 @@ export function parseTweets(html, username, options = {}) {
|
|||
text = extractTextFromHtml(contentMatch[1]);
|
||||
}
|
||||
|
||||
// 이미지
|
||||
// 이미지 / 영상 썸네일
|
||||
const imageUrls = extractImageUrls(container);
|
||||
const videoThumbnails = extractVideoThumbnails(container);
|
||||
|
||||
tweets.push({
|
||||
id,
|
||||
time,
|
||||
text,
|
||||
imageUrls,
|
||||
videoThumbnails,
|
||||
isRetweet,
|
||||
originalUsername,
|
||||
// 긴 트윗(280자 초과)이 …로 잘렸는지 여부 (hydrate 대상 판별용)
|
||||
|
|
@ -232,8 +250,9 @@ export async function fetchSingleTweet(nitterUrl, username, postId) {
|
|||
text = extractTextFromHtml(contentMatch[1]);
|
||||
}
|
||||
|
||||
// 이미지
|
||||
// 이미지 / 영상 썸네일
|
||||
const imageUrls = extractImageUrls(container);
|
||||
const videoThumbnails = extractVideoThumbnails(container);
|
||||
|
||||
// 프로필 정보
|
||||
const profile = extractProfile(html);
|
||||
|
|
@ -243,6 +262,7 @@ export async function fetchSingleTweet(nitterUrl, username, postId) {
|
|||
time,
|
||||
text,
|
||||
imageUrls,
|
||||
videoThumbnails,
|
||||
url: `https://x.com/${username}/status/${postId}`,
|
||||
profile,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -387,6 +387,37 @@ function MobileXSection({ schedule }) {
|
|||
</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">
|
||||
<span className="text-gray-500 text-sm">{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>
|
||||
|
|
|
|||
|
|
@ -166,6 +166,37 @@ function XSection({ schedule }) {
|
|||
</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">
|
||||
<span className="text-gray-500 text-[15px]">{formatXDateTimeWithTime(schedule.date, schedule.time)}</span>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue