From 3e56670e8a91885f63847a559dd11aaad63ee314 Mon Sep 17 00:00:00 2001 From: caadiq Date: Sun, 7 Jun 2026 09:40:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(x-bot):=20TikTok=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=8D=B8=EB=84=A4=EC=9D=BC=20=EC=98=A8=EB=94=94=EB=A7=A8?= =?UTF-8?q?=EB=93=9C=20=ED=94=84=EB=A1=9D=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TikTok은 OG 이미지를 막지만 oEmbed는 thumbnail_url 제공. 단 서명·만료 URL이라 저장하지 않고, /api/schedules/x-card-thumb/:postId 엔드포인트가 요청 시 oEmbed로 현재 썸네일을 받아 302 리다이렉트(Redis 6h 캐시). resolveCard는 TikTok 카드 이미지를 이 프록시 경로로 설정. Co-Authored-By: Claude Opus 4.7 --- backend/src/routes/schedules/index.js | 54 +++++++++++++++++++++++++++ backend/src/services/x/index.js | 7 ++++ backend/src/services/x/og.js | 25 +++++++++++++ 3 files changed, 86 insertions(+) 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 자기 링크는 제외