From d3d3c9cf7518afdb27bbf04504fddb594996f200 Mon Sep 17 00:00:00 2001 From: caadiq Date: Sun, 31 May 2026 19:02:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(x-schedule):=20=ED=8A=B8=EC=9C=97=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=ED=8B=B0=EB=B8=8C=20=EC=98=81=EC=83=81=20?= =?UTF-8?q?=EC=8D=B8=EB=84=A4=EC=9D=BC=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nitter는 영상 파일을 제공하지 않으므로 썸네일만 추출해 표시하고, 재생 시 원본 트윗으로 이동. - scraper: extractVideoThumbnails 추가 (amplify_video_thumb 등) - schedule_x.video_thumbnails 컬럼 + saveTweet 저장 - getScheduleDetail 응답에 videoThumbnails 포함 - PC/모바일 X 상세: 썸네일 + 재생버튼 + 'X에서 재생' 배지 Co-Authored-By: Claude Opus 4.7 --- backend/src/services/schedule.js | 2 ++ backend/src/services/x/index.js | 3 +- backend/src/services/x/scraper.js | 24 ++++++++++++-- .../pages/mobile/schedule/ScheduleDetail.jsx | 31 +++++++++++++++++++ .../pc/public/schedule/sections/XSection.jsx | 31 +++++++++++++++++++ 5 files changed, 88 insertions(+), 3 deletions(-) diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js index 277f6f3..39f1c43 100644 --- a/backend/src/services/schedule.js +++ b/backend/src/services/schedule.js @@ -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) { diff --git a/backend/src/services/x/index.js b/backend/src/services/x/index.js index 43d1f0f..07be8c3 100644 --- a/backend/src/services/x/index.js +++ b/backend/src/services/x/index.js @@ -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, ] ); diff --git a/backend/src/services/x/scraper.js b/backend/src/services/x/scraper.js index 6030720..77d85b3 100644 --- a/backend/src/services/x/scraper.js +++ b/backend/src/services/x/scraper.js @@ -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 = / )} + {/* 영상 썸네일 (재생 버튼 → 원본 트윗으로 이동) */} + {schedule.videoThumbnails?.length > 0 && ( +
+ {schedule.videoThumbnails.map((url, i) => ( + + +
+
+ + + +
+
+ {/* 외부(X) 재생 표시 배지 */} +
+ + + + X에서 재생 +
+
+ ))} +
+ )} + {/* 날짜/시간 */}
{formatXDateTimeWithTime(schedule.date, schedule.time)} diff --git a/frontend/src/pages/pc/public/schedule/sections/XSection.jsx b/frontend/src/pages/pc/public/schedule/sections/XSection.jsx index 35e9d1f..c46ab5c 100644 --- a/frontend/src/pages/pc/public/schedule/sections/XSection.jsx +++ b/frontend/src/pages/pc/public/schedule/sections/XSection.jsx @@ -166,6 +166,37 @@ function XSection({ schedule }) {
)} + {/* 영상 썸네일 (재생 버튼 → 원본 트윗으로 이동) */} + {schedule.videoThumbnails?.length > 0 && ( +
+ {schedule.videoThumbnails.map((url, i) => ( + + +
+
+ + + +
+
+ {/* 외부(X) 재생 표시 배지 */} +
+ + + + X에서 재생 +
+
+ ))} +
+ )} + {/* 날짜/시간 */}
{formatXDateTimeWithTime(schedule.date, schedule.time)}