diff --git a/backend/lib/date.js b/backend/lib/date.js new file mode 100644 index 0000000..efb0225 --- /dev/null +++ b/backend/lib/date.js @@ -0,0 +1,85 @@ +/** + * 날짜/시간 유틸리티 (dayjs 기반) + * 백엔드 전체에서 공통으로 사용 + */ + +import dayjs from "dayjs"; +import utc from "dayjs/plugin/utc.js"; +import timezone from "dayjs/plugin/timezone.js"; +import customParseFormat from "dayjs/plugin/customParseFormat.js"; + +// 플러그인 등록 +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(customParseFormat); + +// 기본 시간대: KST +const KST = "Asia/Seoul"; + +/** + * UTC 시간을 KST로 변환 + * @param {Date|string} utcDate - UTC 시간 + * @returns {dayjs.Dayjs} KST 시간 + */ +export function toKST(utcDate) { + return dayjs(utcDate).tz(KST); +} + +/** + * 날짜를 YYYY-MM-DD 형식으로 포맷 + * @param {Date|string|dayjs.Dayjs} date - 날짜 + * @returns {string} YYYY-MM-DD + */ +export function formatDate(date) { + return dayjs(date).format("YYYY-MM-DD"); +} + +/** + * 시간을 HH:mm:ss 형식으로 포맷 + * @param {Date|string|dayjs.Dayjs} date - 시간 + * @returns {string} HH:mm:ss + */ +export function formatTime(date) { + return dayjs(date).format("HH:mm:ss"); +} + +/** + * UTC 시간을 KST로 변환 후 날짜/시간 분리 + * @param {Date|string} utcDate - UTC 시간 + * @returns {{date: string, time: string}} KST 날짜/시간 + */ +export function utcToKSTDateTime(utcDate) { + const kst = toKST(utcDate); + return { + date: kst.format("YYYY-MM-DD"), + time: kst.format("HH:mm:ss"), + }; +} + +/** + * 현재 KST 시간 반환 + * @returns {dayjs.Dayjs} 현재 KST 시간 + */ +export function nowKST() { + return dayjs().tz(KST); +} + +/** + * Nitter 날짜 문자열 파싱 (UTC 반환) + * 예: "Jan 9, 2026 · 4:00 PM UTC" → Date 객체 + * @param {string} timeStr - Nitter 날짜 문자열 + * @returns {dayjs.Dayjs|null} UTC 시간 + */ +export function parseNitterDateTime(timeStr) { + if (!timeStr) return null; + try { + const cleaned = timeStr.replace(" · ", " ").replace(" UTC", ""); + const date = dayjs.utc(cleaned, "MMM D, YYYY h:mm A"); + if (!date.isValid()) return null; + return date; + } catch (e) { + return null; + } +} + +export default dayjs; diff --git a/backend/package-lock.json b/backend/package-lock.json index e3780da..d8bb465 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,7 +10,9 @@ "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "bcrypt": "^6.0.0", + "dayjs": "^1.11.19", "express": "^4.18.2", + "inko": "^1.1.1", "jsonwebtoken": "^9.0.3", "meilisearch": "^0.55.0", "multer": "^1.4.5-lts.1", @@ -2055,6 +2057,12 @@ "node": ">= 6.0.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", @@ -2099,6 +2107,22 @@ "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", "license": "MIT" }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "license": "ISC" + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -2201,6 +2225,18 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/commander": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", + "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, "node_modules/concat-stream": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", @@ -2258,6 +2294,12 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -2304,6 +2346,15 @@ "node": ">=8" } }, + "node_modules/diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2387,6 +2438,15 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -2496,6 +2556,12 @@ "node": ">= 0.6" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2551,6 +2617,24 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2563,6 +2647,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", + "license": "MIT", + "engines": { + "node": ">=4.x" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2587,6 +2689,15 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", + "integrity": "sha512-z/GDPjlRMNOa2XJiB4em8wJpuuBfrFOlYKTZxtpkdr1uPdibHI8rYA3MY0KDObpVyaes0e/aunid/t88ZI2EKA==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2619,12 +2730,35 @@ "node": ">=0.10.0" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/inko": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/inko/-/inko-1.1.1.tgz", + "integrity": "sha512-Lr+HH4xr1eT0OKokYXjr+lRk6ubVEw6iCpOEGsdeokwLsvLXODWZG2XuzvTOlCl5hEVApK8TVWSkWnf55c6RJA==", + "license": "MIT", + "dependencies": { + "mocha": "^5.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -2839,6 +2973,18 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -2860,6 +3006,60 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mocha": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz", + "integrity": "sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ==", + "license": "MIT", + "dependencies": { + "browser-stdout": "1.3.1", + "commander": "2.15.1", + "debug": "3.1.0", + "diff": "3.5.0", + "escape-string-regexp": "1.0.5", + "glob": "7.1.2", + "growl": "1.10.5", + "he": "1.1.1", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "supports-color": "5.4.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/mocha/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/mocha/node_modules/minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha512-miQKw5Hv4NS1Psg2517mV4e4dYNaO3++hjAvLOAzKqZ61rH8NS1SK+vbfBWZ5PY/Me/bEWhUwqMghEW5Fb9T7Q==", + "license": "MIT" + }, + "node_modules/mocha/node_modules/mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha512-SknJC52obPfGQPnjIkXbmA6+5H15E+fR+E4iR2oQ3zzCLbd7/ONua69R/Gw7AgkTLsRG+r5fzksYwWe1AgTyWA==", + "deprecated": "Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)", + "license": "MIT", + "dependencies": { + "minimist": "0.0.8" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -3004,6 +3204,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3013,6 +3222,15 @@ "node": ">= 0.8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", @@ -3381,6 +3599,18 @@ ], "license": "MIT" }, + "node_modules/supports-color": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -3448,6 +3678,12 @@ "node": ">= 0.8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/xml2js": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", diff --git a/backend/package.json b/backend/package.json index 7d332ab..2161b9d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,9 @@ "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "bcrypt": "^6.0.0", + "dayjs": "^1.11.19", "express": "^4.18.2", + "inko": "^1.1.1", "jsonwebtoken": "^9.0.3", "meilisearch": "^0.55.0", "multer": "^1.4.5-lts.1", diff --git a/backend/routes/schedules.js b/backend/routes/schedules.js index f3e4ba9..b3e291e 100644 --- a/backend/routes/schedules.js +++ b/backend/routes/schedules.js @@ -12,17 +12,22 @@ router.get("/", async (req, res) => { // 검색어가 있으면 Meilisearch 사용 if (search && search.trim()) { const offset = parseInt(req.query.offset) || 0; - const pageLimit = parseInt(req.query.limit) || 20; + const pageLimit = parseInt(req.query.limit) || 100; + + // Meilisearch에서 큰 limit으로 검색 (유사도 필터링 후 클라이언트 페이징) const results = await searchSchedules(search.trim(), { - offset, - limit: pageLimit, + limit: 1000, // 내부적으로 1000개까지 검색 }); + + // 페이징 적용 + const paginatedHits = results.hits.slice(offset, offset + pageLimit); + return res.json({ - schedules: results.hits, + schedules: paginatedHits, total: results.total, - offset: results.offset, - limit: results.limit, - hasMore: results.offset + results.hits.length < results.total, + offset: offset, + limit: pageLimit, + hasMore: offset + paginatedHits.length < results.total, }); } diff --git a/backend/services/meilisearch.js b/backend/services/meilisearch.js index 4ba110b..8308dc5 100644 --- a/backend/services/meilisearch.js +++ b/backend/services/meilisearch.js @@ -110,6 +110,19 @@ export async function deleteSchedule(scheduleId) { } } +import Inko from "inko"; +const inko = new Inko(); + +/** + * 영문 자판으로 입력된 검색어인지 확인 (대부분 영문으로만 구성) + */ +function isEnglishKeyboard(text) { + const englishChars = text.match(/[a-zA-Z]/g) || []; + const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || []; + // 영문이 50% 이상이고 한글이 없으면 영문 자판 입력으로 간주 + return englishChars.length > 0 && koreanChars.length === 0; +} + /** * 일정 검색 (페이징 지원) */ @@ -134,10 +147,33 @@ export async function searchSchedules(query, options = {}) { searchOptions.sort = options.sort; } + // 원본 검색어로 검색 const results = await index.search(query, searchOptions); + let allHits = [...results.hits]; + + // 영문 자판 입력인 경우 한글로 변환하여 추가 검색 + if (isEnglishKeyboard(query)) { + const koreanQuery = inko.en2ko(query); + if (koreanQuery !== query) { + const koreanResults = await index.search(koreanQuery, searchOptions); + // 중복 제거하며 병합 (id 기준) + const existingIds = new Set(allHits.map((h) => h.id)); + for (const hit of koreanResults.hits) { + if (!existingIds.has(hit.id)) { + allHits.push(hit); + existingIds.add(hit.id); + } + } + } + } // 유사도 0.5 미만인 결과 필터링 - const filteredHits = results.hits.filter((hit) => hit._rankingScore >= 0.5); + const filteredHits = allHits.filter((hit) => hit._rankingScore >= 0.5); + + // 유사도 순으로 정렬 + filteredHits.sort( + (a, b) => (b._rankingScore || 0) - (a._rankingScore || 0) + ); // 페이징 정보 포함 반환 return { diff --git a/backend/services/x-bot.js b/backend/services/x-bot.js index 9c1e851..70392f0 100644 --- a/backend/services/x-bot.js +++ b/backend/services/x-bot.js @@ -8,6 +8,12 @@ import pool from "../lib/db.js"; import { addOrUpdateSchedule } from "./meilisearch.js"; +import { + toKST, + formatDate, + formatTime, + parseNitterDateTime, +} from "../lib/date.js"; // YouTube API 키 const YOUTUBE_API_KEY = @@ -19,43 +25,6 @@ const X_CATEGORY_ID = 12; // 유튜브 카테고리 ID const YOUTUBE_CATEGORY_ID = 7; -/** - * UTC → KST 변환 - */ -export function toKST(utcDate) { - const date = new Date(utcDate); - return new Date(date.getTime() + 9 * 60 * 60 * 1000); -} - -/** - * 날짜를 YYYY-MM-DD 형식으로 변환 - */ -export function formatDate(date) { - return date.toISOString().split("T")[0]; -} - -/** - * 시간을 HH:MM:SS 형식으로 변환 - */ -export function formatTime(date) { - return date.toTimeString().split(" ")[0]; -} - -/** - * Nitter 날짜 파싱 ("Jan 7, 2026 · 12:00 PM UTC" → Date) - */ -function parseNitterDateTime(timeStr) { - if (!timeStr) return null; - try { - const cleaned = timeStr.replace(" · ", " ").replace(" UTC", ""); - const date = new Date(cleaned + " UTC"); - if (isNaN(date.getTime())) return null; - return date; - } catch (e) { - return null; - } -} - /** * 트윗 텍스트에서 첫 문단 추출 (title용) */ @@ -625,5 +594,4 @@ export default { syncAllTweets, extractTitle, extractYoutubeVideoIds, - toKST, }; diff --git a/backend/services/youtube-bot.js b/backend/services/youtube-bot.js index 43db427..702ef85 100644 --- a/backend/services/youtube-bot.js +++ b/backend/services/youtube-bot.js @@ -1,6 +1,7 @@ import Parser from "rss-parser"; import pool from "../lib/db.js"; import { addOrUpdateSchedule } from "./meilisearch.js"; +import { toKST, formatDate, formatTime } from "../lib/date.js"; // YouTube API 키 const YOUTUBE_API_KEY = @@ -41,28 +42,6 @@ const rssParser = new Parser({ }, }); -/** - * UTC → KST 변환 - */ -export function toKST(utcDate) { - const date = new Date(utcDate); - return new Date(date.getTime() + 9 * 60 * 60 * 1000); -} - -/** - * 날짜를 YYYY-MM-DD 형식으로 변환 - */ -export function formatDate(date) { - return date.toISOString().split("T")[0]; -} - -/** - * 시간을 HH:MM:SS 형식으로 변환 - */ -export function formatTime(date) { - return date.toTimeString().split(" ")[0]; -} - /** * '유튜브' 카테고리 ID 조회 (없으면 생성) */ @@ -666,5 +645,4 @@ export default { syncNewVideos, syncAllVideos, getYoutubeCategory, - toKST, };