commit 103a69afe761085d13f113f5ea8d234552014d1f Author: imnyang Date: Tue Apr 7 09:56:40 2026 +0900 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bd0602 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +**/*.trace +**/*.zip +**/*.tar.gz +**/*.tgz +**/*.log +package-lock.json +**/*.bun + +.cache +.env \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..e8c94dd --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@imnyang:registry=https://git.mizuki.guru/api/packages/imnyang/npm/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..688c87e --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# Elysia with Bun runtime + +## Getting Started +To get started with this template, simply paste this command into your terminal: +```bash +bun create elysia ./elysia-example +``` + +## Development +To start the development server run: +```bash +bun run dev +``` + +Open http://localhost:3000/ with your browser to see the result. \ No newline at end of file diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..e5d5a83 --- /dev/null +++ b/bun.lock @@ -0,0 +1,140 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "kanade-api", + "dependencies": { + "@elysiajs/openapi": "^1.4.14", + "@elysiajs/swagger": "^1.3.1", + "@imnyang/comcigan.ts": "^0.3.1", + "elysia": "latest", + "lmdb": "^3.5.3", + }, + "devDependencies": { + "bun-types": "latest", + }, + }, + }, + "packages": { + "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], + + "@elysiajs/openapi": ["@elysiajs/openapi@1.4.14", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-kWmJWdvP8/LwHwAJXSpz6xFfYUoyUyEPRimEYABuDU1rOnS27Da1u9T2jyU7frOopxKWV/wDfDxMP8z2xdCPJw=="], + + "@elysiajs/swagger": ["@elysiajs/swagger@1.3.1", "", { "dependencies": { "@scalar/themes": "^0.9.52", "@scalar/types": "^0.0.12", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-LcbLHa0zE6FJKWPWKsIC/f+62wbDv3aXydqcNPVPyqNcaUgwvCajIi+5kHEU6GO3oXUCpzKaMsb3gsjt8sLzFQ=="], + + "@harperfast/extended-iterable": ["@harperfast/extended-iterable@1.0.3", "", {}, "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw=="], + + "@imnyang/comcigan.ts": ["@imnyang/comcigan.ts@0.3.1", "https://git.mizuki.guru/api/packages/imnyang/npm/%40imnyang%2Fcomcigan.ts/-/0.3.1/comcigan.ts-0.3.1.tgz", { "dependencies": { "iconv-lite": "^0.6.3", "undici": "^6.23.0" } }, "sha512-mWeAXUOlBrpWBTPLXJ38hhj7TVyzag23ffgkAbrPXzbN2951NC3Fj9GxXYUQDb5pGvvhWYdKaqbMAiog0zPJJg=="], + + "@lmdb/lmdb-darwin-arm64": ["@lmdb/lmdb-darwin-arm64@3.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Ob379nnG6FpfVi9WUVupUVsMFa0+jbkelilrBAdJgNlg6dDtXKeTi+pzL+G3f1z3SNdXXWUAL5N8LTp7szXEVw=="], + + "@lmdb/lmdb-darwin-x64": ["@lmdb/lmdb-darwin-x64@3.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fbKZ6gonDCWENiXiRoDC4KdBBXi2rlDr1uYj/SErpIAHuTfnLo0Il1hvmbDLeiCPLaPjbXlvcMiR3vLnrNnWMw=="], + + "@lmdb/lmdb-linux-arm": ["@lmdb/lmdb-linux-arm@3.5.3", "", { "os": "linux", "cpu": "arm" }, "sha512-A80EUIRBiKA+0iMc5DxT2u8msgY+K05Lok133IKb3eJVlJkmJie4+LM0MjNyV+mREnu8UwhlNFupSikPy/eTPw=="], + + "@lmdb/lmdb-linux-arm64": ["@lmdb/lmdb-linux-arm64@3.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VYWkuWS8uQSyszMe5KGVJPD3YSkaXVrUz/6hbg3zkBvhfOTyrIVMN9M3cZjpU4yxVRBmGViZ5kgjoKz7n3sw2w=="], + + "@lmdb/lmdb-linux-x64": ["@lmdb/lmdb-linux-x64@3.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-JAeG8rJaL1klzg+VKyRqp0wPSbuPo1ZuNmO/IBRs8QRrSIPxF9r3r1TcIVVRLaaJmI0+2KOjbFRjtvFWFH6cMQ=="], + + "@lmdb/lmdb-win32-arm64": ["@lmdb/lmdb-win32-arm64@3.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-9QdgjU5VW0MJ3wy94h4fQ+cNkxeJ0KasjvisOhLuvlCep2KSOzr1i65Js3ElHBRQX8N+jVD8/+LWIM7flfGZ4w=="], + + "@lmdb/lmdb-win32-x64": ["@lmdb/lmdb-win32-x64@3.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-0nd13c9ypIDkdsJbHv1PMvk6MZrwHLQP05AXZdxvv8lklxRrZxPK2d8nSo8HLoMXyLt0hA2yQ3fydQaSRrkz0g=="], + + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], + + "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], + + "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], + + "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], + + "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], + + "@scalar/openapi-types": ["@scalar/openapi-types@0.1.1", "", {}, "sha512-NMy3QNk6ytcCoPUGJH0t4NNr36OWXgZhA3ormr3TvhX1NDgoF95wFyodGVH8xiHeUyn2/FxtETm8UBLbB5xEmg=="], + + "@scalar/themes": ["@scalar/themes@0.9.86", "", { "dependencies": { "@scalar/types": "0.1.7" } }, "sha512-QUHo9g5oSWi+0Lm1vJY9TaMZRau8LHg+vte7q5BVTBnu6NuQfigCaN+ouQ73FqIVd96TwMO6Db+dilK1B+9row=="], + + "@scalar/types": ["@scalar/types@0.0.12", "", { "dependencies": { "@scalar/openapi-types": "0.1.1", "@unhead/schema": "^1.9.5" } }, "sha512-XYZ36lSEx87i4gDqopQlGCOkdIITHHEvgkuJFrXFATQs9zHARop0PN0g4RZYWj+ZpCUclOcaOjbCt8JGe22mnQ=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], + + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], + + "@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="], + + "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], + + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + + "file-type": ["file-type@22.0.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-cmBmnYo8Zymabm2+qAP7jTFbKF10bQpYmxoGfuZbRFRcq00BRddJdGNH/P7GA1EMpJy5yQbqa9B7yROb3z8Ziw=="], + + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "lmdb": ["lmdb@3.5.3", "", { "dependencies": { "@harperfast/extended-iterable": "^1.0.3", "msgpackr": "^1.11.2", "node-addon-api": "^6.1.0", "node-gyp-build-optional-packages": "5.2.2", "ordered-binary": "^1.5.3", "weak-lru-cache": "^1.2.2" }, "optionalDependencies": { "@lmdb/lmdb-darwin-arm64": "3.5.3", "@lmdb/lmdb-darwin-x64": "3.5.3", "@lmdb/lmdb-linux-arm": "3.5.3", "@lmdb/lmdb-linux-arm64": "3.5.3", "@lmdb/lmdb-linux-x64": "3.5.3", "@lmdb/lmdb-win32-arm64": "3.5.3", "@lmdb/lmdb-win32-x64": "3.5.3" }, "bin": { "download-lmdb-prebuilds": "bin/download-prebuilds.js" } }, "sha512-6A0iRKOv/N62P1vU8qhBr32tHQIY6pQMe8b6zqI4VLELqV8fWHUMdnfNjMR+Kyh7ZeRCo8PDzAnW2g+SJSoP1A=="], + + "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "msgpackr": ["msgpackr@1.11.9", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-FkoAAyyA6HM8wL882EcEyFZ9s7hVADSwG9xrVx3dxxNQAtgADTrJoEWivID82Iv1zWDsv/OtbrrcZAzGzOMdNw=="], + + "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], + + "nanoid": ["nanoid@5.1.7", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ=="], + + "node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="], + + "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + + "ordered-binary": ["ordered-binary@1.6.1", "", {}, "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w=="], + + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], + + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + + "undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "weak-lru-cache": ["weak-lru-cache@1.2.2", "", {}, "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw=="], + + "zhead": ["zhead@2.2.4", "", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="], + + "@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..49c7ac2 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[install.scopes] +imnyang = "https://git.mizuki.guru/api/packages/imnyang/npm/" \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..de9eeac --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "kanade-api", + "version": "1.0.50", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "dev": "bun run --watch src/index.ts" + }, + "dependencies": { + "@elysiajs/openapi": "^1.4.14", + "@elysiajs/swagger": "^1.3.1", + "@imnyang/comcigan.ts": "^0.3.1", + "elysia": "latest", + "lmdb": "^3.5.3" + }, + "devDependencies": { + "bun-types": "latest" + }, + "module": "src/index.js" +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..b8e0161 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,14 @@ +import { Elysia } from "elysia"; +import { openapi } from '@elysiajs/openapi'; + +const app = new Elysia() + .use(openapi()) + .get("/", () => "Hello Elysia") + .use(import("./routes/comcigan")) + .use(import("./routes/neis")) + + .listen(3000); + +console.log( + `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` +); diff --git a/src/routes/comcigan.ts b/src/routes/comcigan.ts new file mode 100644 index 0000000..bd36f8d --- /dev/null +++ b/src/routes/comcigan.ts @@ -0,0 +1,138 @@ +import { Elysia, t } from "elysia"; +import Comcigan, { School, Weekday } from '@imnyang/comcigan.ts'; +import { open } from 'lmdb'; + +const comcigan = new Comcigan(); +type CacheEntry = { + expiresAt: number; + value: any; +}; + +const cacheDb = open('./.cache/comcigan', { + compression: true, +}); + +function getNextDaily8AM(now = new Date()): number { + const next = new Date(now); + next.setHours(8, 0, 0, 0); + + if (now >= next) { + next.setDate(next.getDate() + 1); + } + + return next.getTime(); +} + +function getCacheKey(route: 'search' | 'timetable', params: Record): string { + const sorted = Object.entries(params).sort(([a], [b]) => a.localeCompare(b)); + return `${route}:${JSON.stringify(sorted)}`; +} + +export default new Elysia({ prefix: "/comcigan", tags: ["μ»΄μ‹œκ°„"] }) + .get('/search', async ({ query: { schoolName } }) => { + const cacheKey = getCacheKey('search', { schoolName }); + const cached = cacheDb.get(cacheKey); + const nowMs = Date.now(); + + if (cached && cached.expiresAt > nowMs) { + return cached.value; + } + + if (cached) { + cacheDb.removeSync(cacheKey); + } + + const searchedSchools: School[] = await comcigan.searchSchools(schoolName); + + const result = searchedSchools + .filter((school) => school.code !== 0) // μ—†μœΌλ©΄ μΆ”κ°€ κ²€μƒ‰ν•˜μ„Έμš” μ œμ™Έ + .map((school) => ({ + schoolName: school.name, + schoolCode: school.code, + region: school.region.name, + })); + + cacheDb.putSync(cacheKey, { + expiresAt: getNextDaily8AM(), + value: result, + }); + + return result; + }, + { + query: t.Object({ + schoolName: t.String({ description: '학ꡐ 이름' }), + }), + detail: { summary: '학ꡐ 검색' }, + response: { + 200: t.Array( + t.Object({ + schoolName: t.String({ description: '학ꡐ 이름', default: '선린인터넷고' }), + schoolCode: t.Number({ description: '학ꡐ μ½”λ“œ', default: 41896 }), + region: t.String({ description: 'μ§€μ—­', default: 'μ„œμšΈ' }), + }), + { description: 'κ²€μƒ‰λœ 학ꡐ λͺ©λ‘' } + ), + 400: t.Object({ message: t.String() }, { description: 'μ—λŸ¬ λ©”μ‹œμ§€' }), + }, + } + ) + .get("/timetable/:school", async ({ params: { school }, query: { grade, classNum, weekday, nextWeek } }) => { + const cacheKey = getCacheKey('timetable', { + school, + grade, + classNum, + weekday, + nextWeek: nextWeek ?? false, + }); + const cached = cacheDb.get(cacheKey); + const nowMs = Date.now(); + + if (cached && cached.expiresAt > nowMs) { + return cached.value; + } + + if (cached) { + cacheDb.removeSync(cacheKey); + } + + try { + let result; + if (weekday && classNum !== undefined) { + // νŠΉμ • 반의 νŠΉμ • μš”μΌ + result = await comcigan.getTimetable(school, grade, Number(classNum), Number(weekday), nextWeek); + } else if (weekday && classNum === undefined) { + // ν•™λ…„ μ „μ²΄μ˜ νŠΉμ • μš”μΌ + const fullTimetable = await comcigan.getTimetable(school, grade, nextWeek); + const weekdayIndex = Number(weekday) - 1; // '1' -> 0, '2' -> 1, etc. + result = fullTimetable.map(gradeClasses => gradeClasses.map(classTimetable => classTimetable[weekdayIndex])); + } else if (classNum !== undefined) { + // νŠΉμ • 반의 전체 μ£Ό + result = await comcigan.getTimetable(school, grade, classNum, nextWeek); + } else { + // ν•™λ…„ μ „μ²΄μ˜ 전체 μ£Ό + result = await comcigan.getTimetable(school, grade, nextWeek); + } + + cacheDb.putSync(cacheKey, { + expiresAt: getNextDaily8AM(), + value: result, + }); + + return result; + } catch (e) { + console.error(e); + return { message: 'μ‹œκ°„ν‘œλ₯Ό λΆˆλŸ¬μ˜€λŠ” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.' }; + } + }, { + query: t.Object({ + grade: t.Number({ description: 'ν•™λ…„' }), + classNum: t.Number({ description: '반' }), + weekday: t.Optional(t.Enum(Weekday, { description: 'μš”μΌ (1: μ›”, 2: ν™”, 3: 수, 4: λͺ©, 5: 금)' })), + nextWeek: t.Optional(t.Boolean({default: false, description: 'λ‹€μŒ μ£Ό μ‹œκ°„ν‘œλ₯Ό λΆˆλŸ¬μ˜¬μ§€ μ—¬λΆ€'})), + }), + params: t.Object({ + school: t.Number({ description: '학ꡐ μ½”λ“œ' }) + }), + detail: { summary: 'μ‹œκ°„ν‘œ 쑰회' }, + }) diff --git a/src/routes/lib/neis.ts b/src/routes/lib/neis.ts new file mode 100644 index 0000000..7fe4241 --- /dev/null +++ b/src/routes/lib/neis.ts @@ -0,0 +1,150 @@ +import { open } from 'lmdb'; + +type NeisType = + | 'schoolInfo' + | 'classInfo' + | 'hisTimetable' + | 'misTimetable' + | 'elsTimetable' + | 'spsTimetable' + | 'SchoolSchedule' + | 'mealServiceDietInfo'; + +type CacheEntry = { + expiresAt: number; + value: unknown; +}; + +const NEIS_FETCH_TIMEOUT_MS = 8000; + +let cacheDb: ReturnType> | null = null; +let cacheDisabled = false; + +function getCacheDb(): ReturnType> | null { + if (cacheDisabled) { + return null; + } + + if (cacheDb) { + return cacheDb; + } + + try { + cacheDb = open({ + path: './.cache/neis', + compression: true, + }); + return cacheDb; + } catch (error) { + cacheDisabled = true; + console.error('[neis-cache] LMDB open failed, fallback to no-cache mode', error); + return null; + } +} + +const MONTHLY_CACHE_TYPES: NeisType[] = ['schoolInfo', 'classInfo']; + +function getExpiresAt(type: NeisType, now = new Date()): number { + if (MONTHLY_CACHE_TYPES.includes(type)) { + return new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0, 0).getTime(); + } + + return new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0, 0).getTime(); +} + +function getCacheKey(type: NeisType, params: Record): string { + const sorted = Object.entries(params).sort(([a], [b]) => a.localeCompare(b)); + return `${type}:${JSON.stringify(sorted)}`; +} + +/** + * @param type - schoolInfo | classInfo | hisTimetable | misTimetable | elsTimetable | spsTimetable | SchoolSchedule | mealServiceDietInfo + * @param params - { ATPT_OFCDC_SC_CODE: string, SD_SCHUL_CODE: string } + * @returns Promise + */ +export function neisFetch( + type: NeisType, + params: Record +): Promise { + const baseUrl = 'https://open.neis.go.kr/hub'; + const apiKey = process.env.NEIS_API_KEY; + if (!apiKey) { + throw new Error('NEIS_API_KEY is not set'); + } + + const db = getCacheDb(); + const cacheKey = getCacheKey(type, params); + const cached = db?.get(cacheKey); + const nowMs = Date.now(); + + if (cached && cached.expiresAt > nowMs) { + return Promise.resolve(cached.value); + } + + if (cached) { + db?.removeSync(cacheKey); + } + + const url = new URL(`${baseUrl}/${type}`); + url.searchParams.append('KEY', apiKey!); + url.searchParams.append('Type', 'json'); + Object.entries(params).forEach(([key, value]) => { + url.searchParams.append(key, value); + }); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), NEIS_FETCH_TIMEOUT_MS); + + return fetch(url.toString(), { signal: controller.signal }) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) + .then((data) => { + const value = data; + db?.putSync(cacheKey, { + expiresAt: getExpiresAt(type), + value, + }); + + return value; + }) + .finally(() => clearTimeout(timeout)); +} + +export async function schoolInfoFetch(SD_SCHUL_CODE?: string, SCHUL_NM?: string): Promise { + if (!SD_SCHUL_CODE && !SCHUL_NM) { + throw new Error('SD_SCHUL_CODE λ˜λŠ” SCHUL_NM 쀑 ν•˜λ‚˜λŠ” λ°˜λ“œμ‹œ μ œκ³΅λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€'); + } + if (SD_SCHUL_CODE && SCHUL_NM) { + throw new Error('SD_SCHUL_CODE와 SCHUL_NM은 ν•¨κ»˜ μ‚¬μš©ν•  수 μ—†μŠ΅λ‹ˆλ‹€'); + } + let result; + + if (SD_SCHUL_CODE && !SCHUL_NM) { + result = await neisFetch('schoolInfo', { + SD_SCHUL_CODE: SD_SCHUL_CODE as string, + }); + } + + if (SCHUL_NM && !SD_SCHUL_CODE) { + result = await neisFetch('schoolInfo', { + SCHUL_NM: SCHUL_NM as string, + }); + } + + return result['schoolInfo']?.[1]["row"] || []; +} + +export async function resolveAtptCodeFromSchoolCode(SD_SCHUL_CODE: string): Promise { + const schools = await schoolInfoFetch(SD_SCHUL_CODE); + const atptCode = schools?.[0]?.ATPT_OFCDC_SC_CODE; + + if (!atptCode || typeof atptCode !== 'string') { + throw new Error('학ꡐ μ½”λ“œλ‘œ ꡐ윑청 μ½”λ“œλ₯Ό μ‘°νšŒν•  수 μ—†μŠ΅λ‹ˆλ‹€'); + } + + return atptCode; +} \ No newline at end of file diff --git a/src/routes/neis/index.ts b/src/routes/neis/index.ts new file mode 100644 index 0000000..09de637 --- /dev/null +++ b/src/routes/neis/index.ts @@ -0,0 +1,7 @@ +import { Elysia } from 'elysia'; + +export default new Elysia({ prefix: '/neis', tags: ['NEIS'] }) + .use(import('./info')) + .use(import('./meal')) + .use(import('./schedule')) + .use(import('./timetable')) \ No newline at end of file diff --git a/src/routes/neis/info.ts b/src/routes/neis/info.ts new file mode 100644 index 0000000..eeefe78 --- /dev/null +++ b/src/routes/neis/info.ts @@ -0,0 +1,86 @@ +import { Elysia, t } from 'elysia'; +import { neisFetch, resolveAtptCodeFromSchoolCode, schoolInfoFetch } from '../lib/neis'; + +type ClassSummary = { + CLASS_NM: string; + DDDEP_NM?: string; + ORD_SC_NM?: string; +}; + +type GradeGroupedClassSummary = Record; + +function formatClassInfo(result: any): GradeGroupedClassSummary { + const rows = result?.classInfo?.[1]?.row; + if (!Array.isArray(rows)) { + return {}; + } + + return rows.reduce((acc: GradeGroupedClassSummary, row: any) => { + if (!row?.CLASS_NM) { + return acc; + } + + const grade = String(row.GRADE ?? 'unknown'); + if (!acc[grade]) { + acc[grade] = []; + } + + const item: ClassSummary = { + CLASS_NM: String(row.CLASS_NM), + }; + + if (row.DDDEP_NM) { + item.DDDEP_NM = String(row.DDDEP_NM); + } + + if (row.ORD_SC_NM) { + item.ORD_SC_NM = String(row.ORD_SC_NM); + } + + acc[grade].push(item); + return acc; + }, {} as GradeGroupedClassSummary); +} + +function sortClassByName(grouped: GradeGroupedClassSummary): GradeGroupedClassSummary { + Object.keys(grouped).forEach((grade) => { + grouped[grade].sort((a, b) => { + const aNum = Number(a.CLASS_NM); + const bNum = Number(b.CLASS_NM); + if (!Number.isNaN(aNum) && !Number.isNaN(bNum)) { + return aNum - bNum; + } + return a.CLASS_NM.localeCompare(b.CLASS_NM, 'ko', { numeric: true }); + }); + }); + + return grouped; +} + +export default new Elysia({ prefix: '/info' }) + .get('/school', async ({ query }) => { + return schoolInfoFetch(query.SD_SCHUL_CODE, query.SCHUL_NM); + }, { + query: t.Object({ + SCHUL_NM: t.Optional(t.String({ description: '학ꡐ 이름' })), + SD_SCHUL_CODE: t.Optional(t.String({ description: '학ꡐ μ½”λ“œ' })), + }), + detail: { summary: '학ꡐ 정보 쑰회' }, + }) + .get('/class', async ({ query }) => { + const currentYear = new Date().getFullYear(); + const ATPT_OFCDC_SC_CODE = await resolveAtptCodeFromSchoolCode(query.SD_SCHUL_CODE); + const result = await neisFetch('classInfo', { + ATPT_OFCDC_SC_CODE, + SD_SCHUL_CODE: query.SD_SCHUL_CODE, + AY: currentYear.toString(), + ...(query.GRADE && { GRADE: query.GRADE }), + }); + return sortClassByName(formatClassInfo(result)); + }, { + query: t.Object({ + SD_SCHUL_CODE: t.String({ description: '학ꡐ μ½”λ“œ' }), + GRADE: t.Optional(t.String({ description: 'ν•™λ…„' })), + }), + detail: { summary: 'ν•™κΈ‰ 정보 쑰회' }, + }) \ No newline at end of file diff --git a/src/routes/neis/meal.ts b/src/routes/neis/meal.ts new file mode 100644 index 0000000..55abda1 --- /dev/null +++ b/src/routes/neis/meal.ts @@ -0,0 +1,89 @@ +import { Elysia, t } from 'elysia'; +import { neisFetch, resolveAtptCodeFromSchoolCode } from '../lib/neis'; + +function verifyDateFormat(date: string) { + if (!/^\d{8}$/.test(date)) { + throw new Error('λ‚ μ§œλŠ” YYYYMMDD ν˜•μ‹μ΄μ–΄μ•Ό ν•©λ‹ˆλ‹€'); + } +} + +function getTodayYmd(): string { + return new Date().toISOString().slice(0, 10).replace(/-/g, ''); +} + +function formatMealRows(rows: any[]): any[] { + return rows.map((row) => { + const formatted = { ...row }; + + Object.keys(formatted).forEach((key) => { + if (typeof formatted[key] === 'string') { + formatted[key] = formatted[key].replace(//gi, '\n'); + } + }); + + delete formatted.ATPT_OFCDC_SC_CODE; + delete formatted.ATPT_OFCDC_SC_NM; + delete formatted.SD_SCHUL_CODE; + delete formatted.SCHUL_NM; + delete formatted.LOAD_DTM; + + if ( + formatted.MLSV_FROM_YMD && + formatted.MLSV_TO_YMD && + formatted.MLSV_FROM_YMD === formatted.MLSV_TO_YMD + ) { + formatted.MLSV_YMD = formatted.MLSV_FROM_YMD; + delete formatted.MLSV_FROM_YMD; + delete formatted.MLSV_TO_YMD; + } + + return formatted; + }); +} + +export default new Elysia({ prefix: '/meal' }) + .get('/', async ({ query }) => { + if ('MLSV_YMD' in query && ('MLSV_FROM_YMD' in query || 'MLSV_TO_YMD' in query)) { + throw new Error('MLSV_YMDλŠ” MLSV_FROM_YMD, MLSV_TO_YMD와 ν•¨κ»˜ μ‚¬μš©ν•  수 μ—†μŠ΅λ‹ˆλ‹€'); + } + const ATPT_OFCDC_SC_CODE = await resolveAtptCodeFromSchoolCode(query.SD_SCHUL_CODE); + const { MLSV_YMD, MLSV_FROM_YMD, MLSV_TO_YMD } = query; + if (MLSV_YMD) { + verifyDateFormat(MLSV_YMD); + + const result = await neisFetch('mealServiceDietInfo', { + ATPT_OFCDC_SC_CODE, + SD_SCHUL_CODE: query.SD_SCHUL_CODE, + MLSV_YMD, + }); + return formatMealRows(result['mealServiceDietInfo']?.[1]?.row || []); + } else if (MLSV_FROM_YMD && MLSV_TO_YMD) { + verifyDateFormat(MLSV_FROM_YMD); + verifyDateFormat(MLSV_TO_YMD); + const result = await neisFetch('mealServiceDietInfo', { + ATPT_OFCDC_SC_CODE, + SD_SCHUL_CODE: query.SD_SCHUL_CODE, + MLSV_FROM_YMD, + MLSV_TO_YMD, + }); + return formatMealRows(result['mealServiceDietInfo']?.[1]?.row || []); + } else if (!MLSV_FROM_YMD && !MLSV_TO_YMD) { + const todayYmd = getTodayYmd(); + const result = await neisFetch('mealServiceDietInfo', { + ATPT_OFCDC_SC_CODE, + SD_SCHUL_CODE: query.SD_SCHUL_CODE, + MLSV_YMD: todayYmd, + }); + return formatMealRows(result['mealServiceDietInfo']?.[1]?.row || []); + } else { + throw new Error('MLSV_FROM_YMD와 MLSV_TO_YMDλŠ” ν•¨κ»˜ μ œκ³΅λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€'); + } + }, { + query: t.Object({ + SD_SCHUL_CODE: t.String(), + MLSV_YMD: t.Optional(t.String()), + MLSV_FROM_YMD: t.Optional(t.String()), + MLSV_TO_YMD: t.Optional(t.String()), + }), + detail: { summary: '급식 정보 쑰회' }, + }); \ No newline at end of file diff --git a/src/routes/neis/schedule.ts b/src/routes/neis/schedule.ts new file mode 100644 index 0000000..26d0149 --- /dev/null +++ b/src/routes/neis/schedule.ts @@ -0,0 +1,112 @@ +import { Elysia, t } from 'elysia'; +import { neisFetch, resolveAtptCodeFromSchoolCode } from '../lib/neis'; + +function verifyDateFormat(date: string) { + if (!/^\d{8}$/.test(date)) { + throw new Error('λ‚ μ§œλŠ” YYYYMMDD ν˜•μ‹μ΄μ–΄μ•Ό ν•©λ‹ˆλ‹€'); + } +} + +function getTodayYmd(): string { + return new Date().toISOString().slice(0, 10).replace(/-/g, ''); +} + +function formatGrades(row: any): Record { + const source: Record = { + 1: row.ONE_GRADE_EVENT_YN, + 2: row.TW_GRADE_EVENT_YN, + 3: row.THREE_GRADE_EVENT_YN, + 4: row.FR_GRADE_EVENT_YN, + 5: row.FIV_GRADE_EVENT_YN, + 6: row.SIX_GRADE_EVENT_YN, + }; + + return Object.entries(source).reduce((acc: Record, [grade, value]) => { + const normalized = String(value ?? '').toUpperCase(); + if (normalized === 'Y') { + acc[grade] = true; + } else if (normalized === 'N') { + acc[grade] = false; + } + + return acc; + }, {}); +} + +function formatScheduleRows(rows: any[]): Record>> { + return rows.reduce((acc: Record>>, row: any) => { + const date = String(row?.AA_YMD ?? '').trim(); + const eventName = String(row?.EVENT_NM ?? '').trim(); + if (!date) { + return acc; + } + + if (eventName === 'ν† μš”νœ΄μ—…μΌ') { + return acc; + } + + if (!acc[date]) { + acc[date] = []; + } + + acc[date].push({ + EVENT_NM: row.EVENT_NM, + EVENT_CNTNT: typeof row.EVENT_CNTNT === 'string' + ? row.EVENT_CNTNT.replace(//gi, '\n') + : row.EVENT_CNTNT, + SBTR_DD_SC_NM: row.SBTR_DD_SC_NM, + grades: formatGrades(row), + }); + + return acc; + }, {}); +} + +export default new Elysia({ prefix: '/schedule' }) + .get('/:schoolCode', async ({ params, query }) => { + const { schoolCode } = params; + const { AA_YMD, AA_FROM_YMD, AA_TO_YMD } = query; + + if (AA_YMD && (AA_FROM_YMD || AA_TO_YMD)) { + throw new Error('AA_YMDλŠ” AA_FROM_YMD, AA_TO_YMD와 ν•¨κ»˜ μ‚¬μš©ν•  수 μ—†μŠ΅λ‹ˆλ‹€'); + } + + if ((AA_FROM_YMD && !AA_TO_YMD) || (!AA_FROM_YMD && AA_TO_YMD)) { + throw new Error('AA_FROM_YMD와 AA_TO_YMDλŠ” ν•¨κ»˜ μ œκ³΅λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€'); + } + + if (AA_YMD) { + verifyDateFormat(AA_YMD); + } + + if (AA_FROM_YMD && AA_TO_YMD) { + verifyDateFormat(AA_FROM_YMD); + verifyDateFormat(AA_TO_YMD); + } + + const atptCode = await resolveAtptCodeFromSchoolCode(schoolCode); + + const requestParams: Record = { + ATPT_OFCDC_SC_CODE: atptCode, + SD_SCHUL_CODE: schoolCode, + ...(AA_YMD && { AA_YMD }), + ...(AA_FROM_YMD && AA_TO_YMD && { AA_FROM_YMD, AA_TO_YMD }), + ...(!AA_YMD && !AA_FROM_YMD && !AA_TO_YMD && { AA_YMD: getTodayYmd() }), + }; + + const result = await neisFetch('SchoolSchedule', requestParams); + const rows = result?.SchoolSchedule?.[1]?.row || []; + + return formatScheduleRows(rows); + + }, { + params: t.Object({ + schoolCode: t.String(), + }), + query: t.Object({ + AA_YMD: t.Optional(t.String({ description: 'μ‘°νšŒν•  λ‚ μ§œ (YYYYMMDD)' })), + AA_FROM_YMD: t.Optional(t.String({ description: '쑰회 μ‹œμž‘ λ‚ μ§œ (YYYYMMDD)' })), + AA_TO_YMD: t.Optional(t.String({ description: '쑰회 μ’…λ£Œ λ‚ μ§œ (YYYYMMDD)' })), + }), + detail: { summary: '학ꡐ 행사 일정 쑰회' }, + }); \ No newline at end of file diff --git a/src/routes/neis/timetable.ts b/src/routes/neis/timetable.ts new file mode 100644 index 0000000..1465d7a --- /dev/null +++ b/src/routes/neis/timetable.ts @@ -0,0 +1,108 @@ +import { Elysia, t } from 'elysia'; +import { neisFetch, schoolInfoFetch } from '../lib/neis'; + +type TimetableApiType = 'hisTimetable' | 'misTimetable' | 'elsTimetable' | 'spsTimetable'; + +function verifyDateFormat(date: string) { + if (!/^\d{8}$/.test(date)) { + throw new Error('λ‚ μ§œλŠ” YYYYMMDD ν˜•μ‹μ΄μ–΄μ•Ό ν•©λ‹ˆλ‹€'); + } +} + +function getTodayYmd(): string { + return new Date().toISOString().slice(0, 10).replace(/-/g, ''); +} + +function resolveTimetableType(kind: string | undefined): TimetableApiType { + switch (kind) { + case '고등학ꡐ': + return 'hisTimetable'; + case '쀑학ꡐ': + return 'misTimetable'; + case 'μ΄ˆλ“±ν•™κ΅': + return 'elsTimetable'; + default: + return 'spsTimetable'; + } +} + +function formatTimetableRows(rows: any[]): Record> { + return rows.reduce((acc: Record>, row: any) => { + const date = String(row?.ALL_TI_YMD ?? '').trim(); + const period = String(row?.PERIO ?? '').trim(); + const content = String(row?.ITRT_CNTNT ?? '').replace(//gi, '\n').trim(); + + if (!date || !period || !content) { + return acc; + } + + if (!acc[date]) { + acc[date] = {}; + } + + acc[date][period] = content; + return acc; + }, {}); +} + +export default new Elysia({ prefix: '/timetable' }) + .get('/:schoolCode', async ({ params, query }) => { + const { schoolCode } = params; + const { grade, class: classNum, TI_FROM_YMD, TI_TO_YMD, ALL_TI_YMD } = query; + + if ((grade && !classNum) || (!grade && classNum)) { + throw new Error('grade와 classλŠ” ν•¨κ»˜ μ œκ³΅λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€'); + } + + if (ALL_TI_YMD && (TI_FROM_YMD || TI_TO_YMD)) { + throw new Error('ALL_TI_YMDλŠ” TI_FROM_YMD, TI_TO_YMD와 ν•¨κ»˜ μ‚¬μš©ν•  수 μ—†μŠ΅λ‹ˆλ‹€'); + } + + if ((TI_FROM_YMD && !TI_TO_YMD) || (!TI_FROM_YMD && TI_TO_YMD)) { + throw new Error('TI_FROM_YMD와 TI_TO_YMDλŠ” ν•¨κ»˜ μ œκ³΅λ˜μ–΄μ•Ό ν•©λ‹ˆλ‹€'); + } + + if (ALL_TI_YMD) { + verifyDateFormat(ALL_TI_YMD); + } + + if (TI_FROM_YMD && TI_TO_YMD) { + verifyDateFormat(TI_FROM_YMD); + verifyDateFormat(TI_TO_YMD); + } + + const schools = await schoolInfoFetch(schoolCode); + const school = schools?.[0]; + if (!school) { + throw new Error('학ꡐ 정보λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€'); + } + + const type = resolveTimetableType(school.SCHUL_KND_SC_NM); + + const requestParams: Record = { + ATPT_OFCDC_SC_CODE: String(school.ATPT_OFCDC_SC_CODE), + SD_SCHUL_CODE: schoolCode, + ...(grade && { GRADE: grade }), + ...(classNum && { CLASS_NM: classNum }), + ...(ALL_TI_YMD && { ALL_TI_YMD }), + ...(TI_FROM_YMD && TI_TO_YMD && { TI_FROM_YMD, TI_TO_YMD }), + ...(!ALL_TI_YMD && !TI_FROM_YMD && !TI_TO_YMD && { ALL_TI_YMD: getTodayYmd() }), + }; + + const result = await neisFetch(type, requestParams); + const rows = result?.[type]?.[1]?.row || []; + + return formatTimetableRows(rows); + }, { + params: t.Object({ + schoolCode: t.String(), + }), + query: t.Object({ + grade: t.String({ description: 'ν•™λ…„' }), + class: t.String({ description: 'ν•™κΈ‰' }), + TI_FROM_YMD: t.Optional(t.String({ description: '쑰회 μ‹œμž‘ λ‚ μ§œ (YYYYMMDD)' })), + TI_TO_YMD: t.Optional(t.String({ description: '쑰회 μ’…λ£Œ λ‚ μ§œ (YYYYMMDD)' })), + ALL_TI_YMD: t.Optional(t.String({ description: 'νŠΉμ • λ‚ μ§œμ˜ μ‹œκ°„ν‘œ 쑰회 (YYYYMMDD)' })), + }), + detail: { summary: 'μ‹œκ°„ν‘œ 쑰회' }, + }) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ca2350 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,103 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "ES2022", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + "types": ["bun-types"], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + // "outDir": "./", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + } +}