diff --git a/backend/src/routes/schedules/index.js b/backend/src/routes/schedules/index.js index 0557541..6df0c27 100644 --- a/backend/src/routes/schedules/index.js +++ b/backend/src/routes/schedules/index.js @@ -4,6 +4,7 @@ */ import suggestionsRoutes from './suggestions.js'; import { searchSchedules, syncAllSchedules, deleteSchedule } from '../../services/meilisearch/index.js'; +import { fetchTiktokThumbnail } from '../../services/x/og.js'; import { CATEGORY_IDS } from '../../config/index.js'; import { getCategories, @@ -27,6 +28,59 @@ export default async function schedulesRoutes(fastify) { // 추천 검색어 라우트 등록 fastify.register(suggestionsRoutes, { prefix: '/suggestions' }); + /** + * GET /api/schedules/x-card-thumb/:postId + * 만료성 카드 썸네일(현재 TikTok) 온디맨드 프록시. + * 저장하지 않고 요청 시 oEmbed로 현재 유효한 썸네일을 받아 리다이렉트. + * Redis로 캐싱(6h)하여 호출 최소화. + */ + fastify.get('/x-card-thumb/:postId', { + schema: { + tags: ['schedules'], + summary: 'X 카드 썸네일 프록시 (TikTok 등 만료성 이미지)', + params: { + type: 'object', + properties: { postId: { type: 'string' } }, + required: ['postId'], + }, + }, + }, async (request, reply) => { + const { postId } = request.params; + const cacheKey = `x:cardthumb:${postId}`; + try { + const cached = redis ? await redis.get(cacheKey) : null; + if (cached) return reply.redirect(cached); + + const [rows] = await db.query( + 'SELECT content, card_data FROM schedule_x WHERE post_id = ? LIMIT 1', + [postId] + ); + if (rows.length === 0) return reply.code(404).send(); + + // TikTok URL 추출 (card_data.url 우선, 없으면 본문) + let url = null; + try { + const c = typeof rows[0].card_data === 'string' + ? JSON.parse(rows[0].card_data) : rows[0].card_data; + if (c?.url && /tiktok\.com/.test(c.url)) url = c.url; + } catch { /* noop */ } + if (!url) { + const m = (rows[0].content || '').match(/https?:\/\/[^\s]*tiktok\.com[^\s]*/i); + if (m) url = m[0]; + } + if (!url) return reply.code(404).send(); + + const thumb = await fetchTiktokThumbnail(url); + if (!thumb) return reply.code(404).send(); + + if (redis) await redis.set(cacheKey, thumb, 'EX', 21600); // 6시간 + return reply.redirect(thumb); + } catch (err) { + fastify.log.error(`[x-card-thumb] ${postId}: ${err.message}`); + return reply.code(404).send(); + } + }); + /** * GET /api/schedules/categories * 카테고리 목록 조회 diff --git a/backend/src/services/x/index.js b/backend/src/services/x/index.js index 32c65cc..49aeaf1 100644 --- a/backend/src/services/x/index.js +++ b/backend/src/services/x/index.js @@ -67,6 +67,13 @@ async function xBotPlugin(fastify, opts) { if (needsImage) { const url = (nitter && nitter.url) || extractFirstUrl(tweet.text); + // TikTok은 OG 이미지를 막으므로 oEmbed 썸네일을 온디맨드 프록시 경로로 제공 (만료 URL이라 저장 안 함) + if (url && /tiktok\.com/.test(url)) { + const image = `/api/schedules/x-card-thumb/${tweet.id}`; + return nitter + ? { ...nitter, image } + : { url, title: '', description: '', destination: 'tiktok.com', image }; + } if (url) { try { const og = await fetchOgCard(url); diff --git a/backend/src/services/x/og.js b/backend/src/services/x/og.js index 36a02bd..fb5ec8a 100644 --- a/backend/src/services/x/og.js +++ b/backend/src/services/x/og.js @@ -81,6 +81,31 @@ export async function fetchOgCard(url) { } } +/** + * TikTok oEmbed로 썸네일 URL 조회 + * TikTok은 OG 이미지를 막지만 oEmbed는 thumbnail_url을 제공. + * 단, 반환 URL은 서명·만료형이라 저장하지 말고 온디맨드로 받아 쓸 것. + * @param {string} url - TikTok 영상 URL (vt.tiktok.com 단축 포함) + * @returns {Promise} + */ +export async function fetchTiktokThumbnail(url) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), OG_TIMEOUT); + try { + const res = await fetch(`https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`, { + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; fromis9-bot)' }, + signal: controller.signal, + }); + clearTimeout(timeoutId); + if (!res.ok) return null; + const json = await res.json(); + return json.thumbnail_url || null; + } catch { + clearTimeout(timeoutId); + return null; + } +} + /** * 트윗 본문 텍스트에서 첫 외부 URL 추출 (OG fetch 대상) * t.co / 단축 URL / 일반 http(s) 모두 대상, x.com 자기 링크는 제외