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:
parent
0b730405a6
commit
3e56670e8a
3 changed files with 86 additions and 0 deletions
|
|
@ -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
|
||||
* 카테고리 목록 조회
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 자기 링크는 제외
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue