Add initial project setup with Docker, GitHub Actions, and meal timetable functionality

- Create Dockerfile for multi-stage build
- Set up GitHub Actions workflow for CI/CD
- Implement meal and timetable fetching logic
- Add README and configuration files
- Include cron job for scheduled execution
- Establish .gitignore for build artifacts and environment files
This commit is contained in:
암냥 2026-03-06 23:32:46 +09:00
commit 40116ec84c
No known key found for this signature in database
14 changed files with 519 additions and 0 deletions

35
.github/workflows/main.yml vendored Normal file
View file

@ -0,0 +1,35 @@
on:
push:
branches:
- main
workflow_dispatch:
name: Web Build
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
context: .
file: ./Dockerfile
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
ghcr.io/imnyang/today.sunrin:latest
ghcr.io/imnyang/today.sunrin:${{ github.run_id }}

49
Dockerfile Normal file
View file

@ -0,0 +1,49 @@
FROM oven/bun:alpine AS build
LABEL maintainer="@imnya"
# Set the working directory
COPY app /code/app
COPY .env /code/app/.env
RUN chmod +x /code/app/run.sh
WORKDIR /code/app
RUN mkdir -p /code/app/temp
# Install dependencies
RUN bun install
# Build the project
RUN bun build index.ts --compile --minify --sourcemap --target bun --outfile ./run
FROM oven/bun:alpine AS runner
LABEL maintainer="@imnya"
# Set the working directory
WORKDIR /code
RUN mkdir -p /code/app
# Copy the built files from the build stage
COPY --from=build /code/app/run /code/app/run
COPY --from=build /code/app/.env /code/app/.env
COPY --from=build /code/app/run.sh /code/app/run.sh
RUN mkdir -p /code/app/temp
# Set timezone to Asia/Seoul
RUN apk add --no-cache tzdata \
&& cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime \
&& echo "Asia/Seoul" > /etc/timezone \
&& apk del tzdata
# Cron job
RUN apk add --no-cache curl
RUN curl -Lo /code/app/supercronic https://github.com/aptible/supercronic/releases/latest/download/supercronic-linux-amd64 \
&& chmod +x /code/app/supercronic
RUN mkdir -p /code/app/temp/logs
COPY cron /code/app/cron
RUN chmod +x /code/app/cron
CMD ["/code/app/supercronic", "/code/app/cron"]

34
app/.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

15
app/README.md Normal file
View file

@ -0,0 +1,15 @@
# app
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.3.6. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

70
app/bun.lock Normal file
View file

@ -0,0 +1,70 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "app",
"dependencies": {
"@azure-rest/ai-inference": "^1.0.0-beta.6",
"@imnyang/comcigan.ts": "^0.3.0",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@azure-rest/ai-inference": ["@azure-rest/ai-inference@1.0.0-beta.6", "", { "dependencies": { "@azure-rest/core-client": "^2.1.0", "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-lro": "^2.7.2", "@azure/core-rest-pipeline": "^1.18.2", "@azure/core-tracing": "^1.2.0", "@azure/logger": "^1.1.4", "tslib": "^2.8.1" } }, "sha512-j5FrJDTHu2P2+zwFVe5j2edasOIhqkFj+VkDjbhGkQuOoIAByF0egRkgs0G1k03HyJ7bOOT9BkRF7MIgr/afhw=="],
"@azure-rest/core-client": ["@azure-rest/core-client@2.5.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-EHaOXW0RYDKS5CFffnixdyRPak5ytiCtU7uXDcP/uiY+A6jFRwNGzzJBiznkCzvi5EYpY+YWinieqHb0oY916A=="],
"@azure/abort-controller": ["@azure/abort-controller@2.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA=="],
"@azure/core-auth": ["@azure/core-auth@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" } }, "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg=="],
"@azure/core-lro": ["@azure/core-lro@2.7.2", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-util": "^1.2.0", "@azure/logger": "^1.0.0", "tslib": "^2.6.2" } }, "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw=="],
"@azure/core-rest-pipeline": ["@azure/core-rest-pipeline@1.23.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" } }, "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ=="],
"@azure/core-tracing": ["@azure/core-tracing@1.3.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ=="],
"@azure/core-util": ["@azure/core-util@1.13.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A=="],
"@azure/logger": ["@azure/logger@1.3.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA=="],
"@imnyang/comcigan.ts": ["@imnyang/comcigan.ts@0.3.0", "", { "dependencies": { "iconv-lite": "^0.6.3", "undici": "^6.23.0" } }, "sha512-IqOoqsrziSOZe0vUBvVjCysv8Ydz5uYBoSBiBtX3eopR2b+Em5W1C7mRJwrMa/9Tt2bgZxn30F3yq7w5yJzEdg=="],
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="],
"@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.4", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-CI0NhTrz4EBaa0U+HaaUZrJhPoso8sG7ZFya8uQoBA57fjzrjRSv87ekCjLZOFExN+gXE/z0xuN2QfH4H2HrLQ=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

48
app/index.ts Normal file
View file

@ -0,0 +1,48 @@
import { Weekday } from "@imnyang/comcigan.ts";
import { Meal, Timetable } from "./lib/discord";
async function main() {
// Tomorrow is 1st of the month
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const YYMMDD = tomorrow.toISOString().slice(0, 10).replace(/-/g, "").toString();
// const YYMMDD = "20250306";
const weekday = tomorrow.getDay() === 0 ? 6 : tomorrow.getDay() - 1;
// const weekday = 4;
console.log("📅 | date:", YYMMDD);
console.log("📅 | weekday:", weekday);
console.timeLog("⏱️ | sunrint");
await Timetable({
schoolId: 41896,
grade: 1,
classNum: 1,
weekday: Weekday.Friday,
WEBHOOK_URL: process.env.DISCORD_WEBHOOK_SUNRIN_URL as string
})
await Meal({
MLSV_YMD: YYMMDD,
ATPT_OFCDC_SC_CODE: "B10",
SD_SCHUL_CODE: "7010536",
username: "@today.sunrin",
schoolName: "선린인터넷고등학교",
WEBHOOK_URL: process.env.DISCORD_WEBHOOK_SUNRIN_URL as string
}); // 선린인고
console.timeEnd("⏱️ | sunrint");
console.timeLog("⏱️ | sangjeong");
await Meal({
MLSV_YMD: YYMMDD,
ATPT_OFCDC_SC_CODE: "B10",
SD_SCHUL_CODE: "7010536",
username: "@today.isangjeong",
schoolName: "상정고등학교",
WEBHOOK_URL: process.env.DISCORD_WEBHOOK_SANGJEONG_URL as string
}); // 상정고
console.timeEnd("⏱️ | sangjeong");
};
main().catch((error) => {
console.error("Error in main function:", error);
process.exit(1);
});

7
app/lib/comcigan.ts Normal file
View file

@ -0,0 +1,7 @@
import Comcigan, { School, Weekday } from '@imnyang/comcigan.ts'
const comcigan = new Comcigan()
export default async function getTimetable({ schoolId, grade, classNum, weekday }: { schoolId: number, grade: number, classNum: number, weekday: number }) {
return await comcigan.getTimetable(schoolId, grade, classNum, weekday, false)
}

77
app/lib/discord.ts Normal file
View file

@ -0,0 +1,77 @@
import getTimetable from "./comcigan";
import { getMealInfo, NameToEmoji, removeNutritionInfo } from "./meal";
export async function Meal({ MLSV_YMD, ATPT_OFCDC_SC_CODE, SD_SCHUL_CODE, username, schoolName, WEBHOOK_URL }: { MLSV_YMD: string, ATPT_OFCDC_SC_CODE: string, SD_SCHUL_CODE: string, username: string, schoolName: string, WEBHOOK_URL: string }) {
const mealInfo = await getMealInfo(MLSV_YMD, ATPT_OFCDC_SC_CODE, SD_SCHUL_CODE);
//const isVTS = vts.VTS임(MLSV_YMD);
const lines = removeNutritionInfo(mealInfo.meal).split("\n");
let emojis = (await NameToEmoji(lines.toString())).split(",");
const data = {
content: `${MLSV_YMD.slice(0, 4)}${MLSV_YMD.slice(4, 6)}${MLSV_YMD.slice(6, 8)}일 급식 정보`,
username: username,
embeds: [
{
title: `🏫 | ${schoolName}`,
description: lines.map((line, index) => `${emojis[index] || "❓"} ${line}`).join("\n"),
footer: {
text: `🔥 ${mealInfo.kcal}`
}
},
],
};
console.log("🏓 | Sending Payload");
const response = await fetch(WEBHOOK_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
console.error("Error sending Discord webhook:", response.statusText);
} else {
console.log(`✨ | Payload successfully sent, code ${response.status}`);
}
}
export async function Timetable({ schoolId, grade, classNum, weekday, WEBHOOK_URL }: { schoolId: number, grade: number, classNum: number, weekday: number, WEBHOOK_URL: string }) {
const timetableInfo = await getTimetable({ schoolId, grade, classNum, weekday });
console.log("🏓 | Timetable Info Retrieved", timetableInfo);
const data = {
content: `📅 | ${grade}학년 ${classNum}반 시간표 정보`,
embeds: [
{
title: `🏫 | 학교 : 선린인터넷고등학교`,
fields: (timetableInfo ?? []).map((item) => ({
name: `${item.subject}${item.changed ? (" *") : ""}`,
value: item.teacher,
inline: false,
})),
footer: {
text: `${["월", "화", "수", "목", "금"][weekday - 1]}요일 시간표 정보`
}
},
],
};
console.log("🏓 | Sending Payload");
const response = await fetch(WEBHOOK_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
console.error("Error sending Discord webhook:", response.statusText);
} else {
console.log(`✨ | Payload successfully sent, code ${response.status}`);
}
}

128
app/lib/meal.ts Normal file
View file

@ -0,0 +1,128 @@
import ModelClient, { isUnexpected } from "@azure-rest/ai-inference";
import { AzureKeyCredential } from "@azure/core-auth";
const KEY = process.env.NEIS_API_KEY;
export function removeNutritionInfo(value: string): string {
const lines = value.trim().split('\n');
const cleanedLines = lines.map(line => line.replace(/\s*\([\d.,]+\)/g, '').trim());
const result = cleanedLines.join('\n');
return result;
}
const nutritionList = [
"난류", "우유", "메밀", "땅콩", "대두", "밀", "고등어", "게", "새우", "돼지고기",
"복숭아", "토마토", "아황산류", "호두", "닭고기", "쇠고기", "오징어", "조개류(굴, 전복, 홍합 포함)", "잣"
];
export function getNutritionInfo(value: string): string[][] {
const lines = value.trim().split('\n');
return lines.map(line => {
const indexes = line
.replace(/[()\s]/g, "")
.split(".")
.map(v => parseInt(v, 10) - 1)
.filter(i => i >= 0 && i < nutritionList.length);
return indexes
.map(i => nutritionList[i])
.filter((item): item is string => typeof item === "string");
});
}
export async function getMealInfo(MLSV_YMD: string, ATPT_OFCDC_SC_CODE: string, SD_SCHUL_CODE: string): Promise<{ meal: string; date: string, kcal: string }> {
const url = `https://open.neis.go.kr/hub/mealServiceDietInfo?Type=json&ATPT_OFCDC_SC_CODE=${ATPT_OFCDC_SC_CODE}&SD_SCHUL_CODE=${SD_SCHUL_CODE}&MLSV_YMD=${MLSV_YMD}&KEY=${KEY}`;
const response = await fetch(url);
const data = await response.json();
// @ts-ignore
const DDISH_NM = data.mealServiceDietInfo[1].row[0].DDISH_NM;
return {
meal: DDISH_NM.replace(/<br\s*\/?>/gi, '\n'),
date: MLSV_YMD,
// @ts-ignore
kcal: data.mealServiceDietInfo[1].row[0].CAL_INFO,
};
}
export async function NameToEmoji(name: string): Promise<string> {
const token = process.env.GITHUB_TOKEN;
if (!token) {
throw new Error("GITHUB_TOKEN environment variable is not set.");
}
const endpoint = "https://models.github.ai/inference";
const model = "openai/gpt-5-mini";
const client = ModelClient(
endpoint,
new AzureKeyCredential(token),
);
const systemPrompt = `⚠️ 중요한 지침: 당신은 오직 이모지로만 응답하는 AI입니다. 다음 규칙을 예외 없이 철저히 준수해야 합니다. ⚠️
1. :
, . 🎯
, . 🚫
2. :
(,) ( ) , (,) . 🔢
.
3. ( ):
. ( !) 1
(, , , , ) . 📝
. 💯
4. :
[1], [2], [3]... ( ) 🔄
🌟 ( ):
Q: 현미찹쌀밥, ,
A: 🍚,🐶,
Q: 행복, ,
A: 😊,😢,😮
Q: 안녕하세요,
A: 👋,🤝
Q: 사랑, ,
A: 🥰,,🗽
`;
const response = await client.path("/chat/completions").post({
body: {
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: name }
],
temperature: 1.0,
top_p: 1.0,
model: model
}
});
if (isUnexpected(response)) {
throw response.body.error;
}
const choices = response.body?.choices;
if (!choices || !choices[0]?.message?.content) {
throw new Error("No valid response from the model.");
}
return choices[0].message.content as string;
}

16
app/package.json Normal file
View file

@ -0,0 +1,16 @@
{
"name": "app",
"module": "index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@azure-rest/ai-inference": "^1.0.0-beta.6",
"@imnyang/comcigan.ts": "^0.3.0"
}
}

3
app/run.sh Normal file
View file

@ -0,0 +1,3 @@
cd /code/app
./run

7
app/test.ts Normal file
View file

@ -0,0 +1,7 @@
import Comcigan, { School, Weekday } from '@imnyang/comcigan.ts'
const comcigan = new Comcigan()
console.log(await School.fromName('선린인터넷고'))
console.log(await comcigan.getTimetable(41896, 1,1, Weekday.Friday))

29
app/tsconfig.json Normal file
View file

@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

1
cron Normal file
View file

@ -0,0 +1 @@
0 6 * * * /code/app/run.sh >> /code/app/temp/logs/run-$(date +%Y%m%d-%H%M%S).log 2>&1