feat(x-bot): TikTok 카드 썸네일 온디맨드 프록시

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 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-06-07 09:40:32 +09:00
parent 0b730405a6
commit 3e56670e8a
3 changed files with 86 additions and 0 deletions

View file

@ -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
* 카테고리 목록 조회

View file

@ -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);

View file

@ -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<string|null>}
*/
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 자기 링크는 제외