2026-01-19 12:57:06 +09:00
|
|
|
import { fetchSingleTweet, extractTitle } from '../../services/x/scraper.js';
|
|
|
|
|
import { addOrUpdateSchedule } from '../../services/meilisearch/index.js';
|
|
|
|
|
import { formatDate, formatTime } from '../../utils/date.js';
|
2026-01-21 13:38:25 +09:00
|
|
|
import config, { CATEGORY_IDS } from '../../config/index.js';
|
2026-01-19 12:57:06 +09:00
|
|
|
|
2026-01-21 13:38:25 +09:00
|
|
|
const X_CATEGORY_ID = CATEGORY_IDS.X;
|
2026-01-19 12:57:06 +09:00
|
|
|
const NITTER_URL = config.nitter?.url || process.env.NITTER_URL || 'http://nitter:8080';
|
2026-01-21 14:11:35 +09:00
|
|
|
const DEFAULT_USERNAME = config.x.defaultUsername;
|
2026-01-19 12:57:06 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* X(Twitter) 관련 관리자 라우트
|
|
|
|
|
*/
|
|
|
|
|
export default async function xRoutes(fastify) {
|
|
|
|
|
const { db, meilisearch } = fastify;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* GET /api/admin/x/post-info
|
|
|
|
|
* X 게시글 정보 조회
|
|
|
|
|
*/
|
|
|
|
|
fastify.get('/post-info', {
|
|
|
|
|
schema: {
|
|
|
|
|
tags: ['admin/x'],
|
|
|
|
|
summary: 'X 게시글 정보 조회',
|
|
|
|
|
security: [{ bearerAuth: [] }],
|
|
|
|
|
querystring: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
postId: { type: 'string', description: '게시글 ID' },
|
|
|
|
|
username: { type: 'string', description: '사용자명 (기본: realfromis_9)' },
|
|
|
|
|
},
|
|
|
|
|
required: ['postId'],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
preHandler: [fastify.authenticate],
|
|
|
|
|
}, async (request, reply) => {
|
|
|
|
|
const { postId, username = DEFAULT_USERNAME } = request.query;
|
|
|
|
|
|
|
|
|
|
// 게시글 ID 유효성 검사
|
|
|
|
|
if (!/^\d+$/.test(postId)) {
|
|
|
|
|
return reply.code(400).send({ error: '유효하지 않은 게시글 ID입니다.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const tweet = await fetchSingleTweet(NITTER_URL, username, postId);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
postId: tweet.id,
|
|
|
|
|
username,
|
|
|
|
|
text: tweet.text,
|
|
|
|
|
title: extractTitle(tweet.text),
|
|
|
|
|
imageUrls: tweet.imageUrls,
|
|
|
|
|
date: tweet.time ? formatDate(tweet.time) : null,
|
|
|
|
|
time: tweet.time ? formatTime(tweet.time) : null,
|
|
|
|
|
postUrl: tweet.url,
|
|
|
|
|
profile: tweet.profile,
|
|
|
|
|
};
|
|
|
|
|
} catch (err) {
|
|
|
|
|
fastify.log.error(`X 게시글 조회 오류: ${err.message}`);
|
|
|
|
|
return reply.code(500).send({ error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* POST /api/admin/x/schedule
|
|
|
|
|
* X 일정 저장
|
|
|
|
|
*/
|
|
|
|
|
fastify.post('/schedule', {
|
|
|
|
|
schema: {
|
|
|
|
|
tags: ['admin/x'],
|
|
|
|
|
summary: 'X 일정 저장',
|
|
|
|
|
security: [{ bearerAuth: [] }],
|
|
|
|
|
body: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
postId: { type: 'string' },
|
|
|
|
|
title: { type: 'string' },
|
|
|
|
|
content: { type: 'string' },
|
|
|
|
|
imageUrls: { type: 'array', items: { type: 'string' } },
|
|
|
|
|
date: { type: 'string' },
|
|
|
|
|
time: { type: 'string' },
|
|
|
|
|
},
|
|
|
|
|
required: ['postId', 'title', 'date'],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
preHandler: [fastify.authenticate],
|
|
|
|
|
}, async (request, reply) => {
|
|
|
|
|
const { postId, title, content, imageUrls, date, time } = request.body;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 중복 체크
|
|
|
|
|
const [existing] = await db.query(
|
|
|
|
|
'SELECT id FROM schedule_x WHERE post_id = ?',
|
|
|
|
|
[postId]
|
|
|
|
|
);
|
|
|
|
|
if (existing.length > 0) {
|
|
|
|
|
return reply.code(409).send({ error: '이미 등록된 게시글입니다.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// schedules 테이블에 저장
|
|
|
|
|
const [result] = await db.query(
|
|
|
|
|
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
|
|
|
|
[X_CATEGORY_ID, title, date, time || null]
|
|
|
|
|
);
|
|
|
|
|
const scheduleId = result.insertId;
|
|
|
|
|
|
|
|
|
|
// schedule_x 테이블에 저장
|
|
|
|
|
await db.query(
|
|
|
|
|
'INSERT INTO schedule_x (schedule_id, post_id, content, image_urls) VALUES (?, ?, ?, ?)',
|
|
|
|
|
[scheduleId, postId, content || null, imageUrls?.length > 0 ? JSON.stringify(imageUrls) : null]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Meilisearch 동기화
|
|
|
|
|
const [categoryRows] = await db.query(
|
|
|
|
|
'SELECT name, color FROM schedule_categories WHERE id = ?',
|
|
|
|
|
[X_CATEGORY_ID]
|
|
|
|
|
);
|
|
|
|
|
const category = categoryRows[0] || {};
|
|
|
|
|
|
|
|
|
|
await addOrUpdateSchedule(meilisearch, {
|
|
|
|
|
id: scheduleId,
|
|
|
|
|
title,
|
|
|
|
|
date,
|
|
|
|
|
time: time || '',
|
|
|
|
|
category_id: X_CATEGORY_ID,
|
|
|
|
|
category_name: category.name || '',
|
|
|
|
|
category_color: category.color || '',
|
|
|
|
|
source_name: '',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { success: true, scheduleId };
|
|
|
|
|
} catch (err) {
|
|
|
|
|
fastify.log.error(`X 일정 저장 오류: ${err.message}`);
|
|
|
|
|
return reply.code(500).send({ error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|