This commit is contained in:
암냥 2025-12-11 01:16:37 +09:00
commit e176b1c094
No known key found for this signature in database
17 changed files with 632 additions and 0 deletions

1
.env.example Normal file
View file

@ -0,0 +1 @@
DISCORD_TOKEN=your_discord_bot_token_here

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

@ -0,0 +1,53 @@
name: ci
on:
push:
branches:
- main
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set version
run: |
if [[ "${GITHUB_REF}" == refs/heads/main ]]; then
echo "RELEASE_VERSION=latest" >> $GITHUB_ENV
else
echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
fi
- 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 image
uses: docker/build-push-action@v6
with:
context: .
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
ghcr.io/imnyang/yanmang/bot:latest
ghcr.io/imnyang/yanmang/bot:${{ env.RELEASE_VERSION }}
- name: Create GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

37
.gitignore vendored Normal file
View file

@ -0,0 +1,37 @@
# 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
processed.json
version

31
Dockerfile Normal file
View file

@ -0,0 +1,31 @@
FROM oven/bun:alpine as build
# Set timezone to KST
ENV TZ=Asia/Seoul
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
# Copy dependency files first (improves layer caching)
COPY package.json bun.lock ./
# Install dependencies (cached until package files change)
RUN bun install --frozen-lockfile
# Copy application code
COPY . .
RUN bun run ./cli/git-commit-build.ts
RUN bun run build
FROM alpine:latest as runtime
ENV TZ=Asia/Seoul
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
COPY --from=build /app/yanmang .
COPY --from=build /app/version .
CMD ["./yanmang"]

15
README.md Normal file
View file

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

86
bun.lock Normal file
View file

@ -0,0 +1,86 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "yanmang",
"dependencies": {
"discord.js": "^14.25.1",
"rss-parser": "^3.13.0",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@discordjs/builders": ["@discordjs/builders@1.13.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.33", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w=="],
"@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
"@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="],
"@discordjs/rest": ["@discordjs/rest@2.6.0", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.16", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w=="],
"@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="],
"@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
"@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
"@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
"@types/node": ["@types/node@24.10.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
"discord-api-types": ["discord-api-types@0.38.36", "", {}, "sha512-qrbUbjjwtyeBg5HsAlm1C859epfOyiLjPqAOzkdWlCNsZCWJrertnETF/NwM8H+waMFU58xGSc5eXUfXah+WTQ=="],
"discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="],
"entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
"magic-bytes.js": ["magic-bytes.js@1.12.1", "", {}, "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA=="],
"rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "^2.0.3", "xml2js": "^0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="],
"sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="],
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
"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.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
"@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
}
}

13
cli/git-commit-build.ts Normal file
View file

@ -0,0 +1,13 @@
async function getGitCommitHash(): Promise<string> {
try {
const proc = Bun.spawn(["git", "rev-parse", "--short", "HEAD"]);
const text = await new Response(proc.stdout).text();
return text.trim();
} catch {
return "unknown";
}
}
Bun.file("version").write(await getGitCommitHash());
export {};

23
package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "yanmang",
"module": "index.ts",
"type": "module",
"private": true,
"scripts": {
"start": "bun run ./src/index.ts",
"dev": "bun --hot run ./src/index.ts",
"build": "bun build ./src/index.ts --outfile yanmang --compile --minify",
"lint": "biome check .",
"lint:fix": "biome format ."
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"discord.js": "^14.25.1",
"rss-parser": "^3.13.0"
}
}

12
rss.toml Normal file
View file

@ -0,0 +1,12 @@
[geeknews]
channel = "1448338163864174867"
tag = "1448338238963056691"
emoji = "🤓"
rss = "https://feeds.feedburner.com/geeknews-feed"
[svrforum]
channel = "1448338163864174867"
tag = "1448338289147908108"
emoji = "💻"
rss = "https://svrforum.com/rss"
category_filter = ["유머&정보"]

79
src/commands.ts Normal file
View file

@ -0,0 +1,79 @@
import {
SlashCommandBuilder,
PermissionFlagsBits,
ChatInputCommandInteraction,
ForumChannel,
Client,
} from "discord.js";
export const commands = [
new SlashCommandBuilder()
.setName("clear-forum")
.setDescription("포럼 채널의 모든 게시물을 삭제합니다")
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator)
.addChannelOption((option) =>
option
.setName("channel")
.setDescription("삭제할 포럼 채널 (미지정 시 현재 채널)")
.setRequired(false)
)
.toJSON(),
];
export async function handleClearForum(
interaction: ChatInputCommandInteraction,
client: Client
): Promise<void> {
const targetChannel = interaction.options.getChannel("channel") || interaction.channel;
if (!targetChannel) {
await interaction.reply({ content: "Channel is not found", ephemeral: true });
return;
}
const channel = await client.channels.fetch(targetChannel.id);
if (!channel || !(channel instanceof ForumChannel)) {
await interaction.reply({ content: "Channel is not a forum channel", ephemeral: true });
return;
}
await interaction.deferReply({ ephemeral: true });
try {
const [activeThreads, archivedThreads] = await Promise.all([
channel.threads.fetch(),
channel.threads.fetchArchived(),
]);
const allThreads = [
...activeThreads.threads.values(),
...archivedThreads.threads.values(),
];
if (allThreads.length === 0) {
await interaction.editReply("No posts to delete.");
return;
}
let deleted = 0;
let failed = 0;
for (const thread of allThreads) {
try {
await thread.delete();
deleted++;
} catch (error) {
console.error(`Failed to delete thread: ${thread.name}`, error);
failed++;
}
}
await interaction.editReply(
`Deleted: ${deleted}\nFailed: ${failed}`
);
} catch (error) {
console.error("Error occurred while deleting forum posts:", error);
await interaction.editReply("❌ An error occurred while deleting posts.");
}
}

38
src/index.ts Normal file
View file

@ -0,0 +1,38 @@
import { Client, GatewayIntentBits, REST, Routes } from "discord.js";
import { token, INTERVAL_MS } from "./lib/config";
import { commands, handleClearForum } from "./commands";
import { checkRssFeeds } from "./lib/rss";
const client = new Client({
intents: [GatewayIntentBits.Guilds],
});
client.once("clientReady", async () => {
console.log(`Logged in as: ${client.user?.tag}`);
// Register slash commands
try {
const rest = new REST().setToken(token!);
await rest.put(Routes.applicationCommands(client.user!.id), { body: commands });
console.log("✅ Slash commands registered successfully");
} catch (error) {
console.error("Failed to register slash commands:", error);
}
// Start checking RSS feeds
checkRssFeeds(client);
setInterval(() => checkRssFeeds(client), INTERVAL_MS);
console.log(`⏰ Checking RSS feeds every ${process.env.INTERVAL_MINUTES ? process.env.INTERVAL_MINUTES : 5} minutes`);
});
client.on("interactionCreate", async (interaction) => {
if (!interaction.isChatInputCommand()) return;
switch (interaction.commandName) {
case "clear-forum":
await handleClearForum(interaction, client);
break;
}
});
client.login(token);

20
src/lib/config.ts Normal file
View file

@ -0,0 +1,20 @@
import Parser from "rss-parser";
export const PROCESSED_FILE = "./processed.json";
export const INTERVAL_MS =
process.env.INTERVAL_MINUTES
? parseInt(process.env.INTERVAL_MINUTES) * 60 * 1000
: 5 * 60 * 1000;
export const parser = new Parser({
headers: {
"User-Agent": process.env.USER_AGENT || "NekoRSS/1.0 (+abuse@imnya.ng)",
},
});
export const token = process.env.DISCORD_TOKEN;
if (!token) {
console.error("Discord Token is not set in environment variables.");
process.exit(1);
}

133
src/lib/rss.ts Normal file
View file

@ -0,0 +1,133 @@
import { ForumChannel, ThreadAutoArchiveDuration, Client } from "discord.js";
import type Parser from "rss-parser";
import { parser } from "./config";
import { processedItems, saveCurrentState } from "./storage";
import type { RssSource } from "../type/types";
import rssConfig from "@/../rss.toml";
export async function checkRssFeeds(client: Client): Promise<void> {
console.log(`[${new Date().toISOString()}] Checking RSS feeds...`);
for (const [name, config] of Object.entries(rssConfig) as [string, RssSource][]) {
try {
await processRssFeed(name, config, client);
} catch (error) {
console.error(`[${name}] Failed to check RSS feed:`, error);
}
}
}
async function processRssFeed(
name: string,
config: RssSource,
client: Client
): Promise<void> {
const feed = await parser.parseURL(config.rss);
if (!processedItems.has(name)) {
processedItems.set(name, new Set());
}
const processed = processedItems.get(name)!;
// 현재 RSS 피드에 있는 항목들의 ID 수집
const currentFeedIds = new Set(
feed.items.map((item) => item.link || item.guid || "").filter(Boolean)
);
// RSS 피드에서 사라진 항목들을 처리된 목록에서 제거
const removedItems: string[] = [];
for (const itemId of processed) {
if (!currentFeedIds.has(itemId)) {
removedItems.push(itemId);
}
}
if (removedItems.length > 0) {
for (const itemId of removedItems) {
processed.delete(itemId);
}
console.log(`[${name}] Cleaned up ${removedItems.length} old items from processed list`);
saveCurrentState();
}
for (const item of feed.items) {
const itemId = item.link || item.guid || "";
if (!itemId || processed.has(itemId)) continue;
// 카테고리 필터링
if (shouldFilterByCategory(config, item)) {
console.log(
`[${name}] Filtered by category: ${item.title} (${item.categories?.join(", ")})`
);
processed.add(itemId);
continue;
}
console.log(`[${name}] New item found: ${item.title}`);
await postToForum(config, item, client);
processed.add(itemId);
saveCurrentState();
}
}
function shouldFilterByCategory(config: RssSource, item: Parser.Item): boolean {
if (!config.category_filter?.length) return false;
const categories = item.categories || [];
return categories.some((cat) => config.category_filter!.includes(cat));
}
async function postToForum(
config: RssSource,
item: Parser.Item,
client: Client
): Promise<void> {
const startTime = performance.now();
try {
const channel = await client.channels.fetch(config.channel);
if (!channel || !(channel instanceof ForumChannel)) {
console.error(`Channel ${config.channel} not found or is not a forum channel.`);
return;
}
const title = (item.title || "Untitled").slice(0, 100);
const content = buildContent(config, item);
await channel.threads.create({
name: title,
autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek,
message: { content },
appliedTags: [config.tag],
});
const endTime = performance.now();
console.log(`[Forum] Post completed: ${title} (${(endTime - startTime).toFixed(2)}ms)`);
} catch (error) {
const endTime = performance.now();
console.error(`[Forum] Post failed (${(endTime - startTime).toFixed(2)}ms):`, error);
}
}
function buildContent(config: RssSource, item: Parser.Item): string {
const parts: string[] = [];
if (item.link) {
parts.push(`# ${config.emoji} | [${item.title}](<${item.link}>)`);
}
if (item.contentSnippet) {
parts.push(`\n${item.content}\n`);
}
if (item.pubDate) {
const timestamp = Math.floor(new Date(item.pubDate).getTime() / 1000);
parts.push(`\n-# 🕐 <t:${timestamp}:f>`);
}
return parts.join("\n") || item.link || "No content available.";
}

35
src/lib/storage.ts Normal file
View file

@ -0,0 +1,35 @@
import { readFileSync, writeFileSync, existsSync } from "fs";
import { PROCESSED_FILE } from "./config";
export function loadProcessedItems(): Record<string, string[]> {
if (existsSync(PROCESSED_FILE)) {
try {
return JSON.parse(readFileSync(PROCESSED_FILE, "utf-8"));
} catch (error) {
console.error("처리된 항목 파일 읽기 실패:", error);
}
}
return {};
}
export function saveProcessedItems(items: Record<string, string[]>): void {
try {
writeFileSync(PROCESSED_FILE, JSON.stringify(items, null, 2));
} catch (error) {
console.error("처리된 항목 파일 저장 실패:", error);
}
}
// 처리된 항목 Map
const processedData = loadProcessedItems();
export const processedItems = new Map<string, Set<string>>(
Object.entries(processedData).map(([k, v]) => [k, new Set(v)])
);
export function saveCurrentState(): void {
saveProcessedItems(
Object.fromEntries(
Array.from(processedItems.entries()).map(([k, v]) => [k, Array.from(v)])
)
);
}

11
src/type/toml.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
declare module "*.toml" {
const value: Record<string, {
channel: string;
tag: string;
emoji: string;
body: string;
rss: string;
category_filter?: string[];
}>;
export default value;
}

8
src/type/types.ts Normal file
View file

@ -0,0 +1,8 @@
export interface RssSource {
channel: string;
tag: string;
emoji: string;
body: string;
rss: string;
category_filter?: string[];
}

37
tsconfig.json Normal file
View file

@ -0,0 +1,37 @@
{
"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,
// Path aliases
"baseUrl": "./src",
"paths": {
"@/*": ["*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}