diff --git a/.env.example b/.env.example index 025c10e..836f4ed 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ PORT=1234 -DEBUG=true - +DEBUG=false +TELEGRAM_BOT_TOKEN=abcd +TELEGRAM_CHAT_ID=efgh diff --git a/.eslintrc.json b/.eslintrc.json index 1f5c47b..bd0edbc 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,25 +1,25 @@ { - "parser": "@babel/eslint-parser", - "parserOptions": { - "ecmaVersion": 2021, - "sourceType": "module", - "babelOptions": { - "presets": [ - "@babel/preset-env", - "@babel/preset-react" - ] - } - }, - "env": { - "browser": true, - "node": true - }, - "plugins": [ - "react" - ], - "extends": [ - "eslint:recommended", - "plugin:react/recommended" - ], - "rules": {} -} \ No newline at end of file + "parser": "@babel/eslint-parser", + "parserOptions": { + "ecmaVersion": 2021, + "sourceType": "module", + "babelOptions": { + "presets": ["@babel/preset-env", "@babel/preset-react"] + } + }, + "env": { + "browser": true, + "node": true + }, + "plugins": ["react", "@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:@typescript-eslint/recommended" + ], + "rules": { + "@typescript-eslint/explicit-module-boundary-types": "error", + "@typescript-eslint/explicit-function-return-type": "error" + } +} + diff --git a/bun.lockb b/bun.lockb index 84a97f4..0b8c800 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 483c9e4..7615faf 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,11 @@ "build:css": "unocss \"src/**/*.tsx\" -o public/styles/uno.css", "css": "unocss --watch \"src/**/*.tsx\" -o public/styles/uno.css", "db": "bun run src/db.ts", - "dev": "bun install && concurrently --restart-tries=3 \"bun css\" \"nodemon --watch src --ext ts,tsx --exec 'bun run --hot src/server.tsx'\"", + "dev": "bun install && concurrently --restart-tries=3 \"bun css\" \"nodemon --watch src --ext ts,tsx --exec 'bun run --hot src/server.tsx'\" \"bun run src/worker.ts\"", "prettier": "bunx prettier --write src/ test/ --plugin prettier-plugin-tailwindcss", "server": "bun run --hot src/server.tsx", - "test": "NODE_ENV=test bun run test" + "test": "NODE_ENV=test bun run test", + "worker": "bun run src/worker.ts" }, "dependencies": { "@unocss/preset-web-fonts": "^0.61.0", @@ -21,6 +22,7 @@ "zod": "^3.23.5" }, "devDependencies": { + "@types/bun": "^1.1.5", "@unocss/cli": "^0.61.0", "bun-types": "^1.1.8", "concurrently": "^8.2.1", diff --git a/src/schedule.ts b/src/schedule.ts deleted file mode 100644 index 185a28d..0000000 --- a/src/schedule.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { fetchAndStoreArticles } from "./util/api"; - -const scheduleArticleUpdate = async () => { - try { - await fetchAndStoreArticles(); - const successMessage = "Articles fetched and stored successfully."; - console.log(successMessage); - } catch (error) { - const errorMessage = `Error fetching articles: ${error.message}`; - console.error(errorMessage); - } -}; - -setInterval(scheduleArticleUpdate, 1000 * 60 * 60); - -scheduleArticleUpdate(); diff --git a/src/server.tsx b/src/server.tsx index 1502808..69a3b96 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -8,9 +8,6 @@ import { isCacheValid, } from "./util/api"; -// Triggers long-running processes -import "./schedule"; - const app = new Hono(); app.use("/styles/*", serveStatic({ root: "./public/" })); diff --git a/src/util/api.ts b/src/util/api.ts index 7bb03ab..89806d9 100644 --- a/src/util/api.ts +++ b/src/util/api.ts @@ -4,6 +4,7 @@ import { Article } from "../types"; import shuffle from "./shuffle"; import { newsSources, NewsSource } from "./newsSources"; import { isValidArticle, insertArticle } from "./articleUtils"; +import { debug, log } from "./log"; const generateIdFromTitle = (title: string): string => { return Bun.hash(title).toString(); @@ -12,16 +13,12 @@ const generateIdFromTitle = (title: string): string => { const fetchArticlesFromSource = async ( source: NewsSource, ): Promise => { - if (process.env["DEBUG"] === "true") { - console.log(`*** Fetching articles from: ${source.name}`); - } + log(`Fetching articles from: ${source.name}`); const response = await fetch(source.url); const text = await response.text(); - if (process.env["DEBUG"] === "true") { - console.log(`*** FETCHING: ${source.name}`); - } + debug(`FETCHING: ${source.name}`); const $ = load(text); const articles: Article[] = []; @@ -41,56 +38,38 @@ const fetchArticlesFromSource = async ( created_at: new Date().toISOString(), }; if (!isValidArticle(article)) { - if (process.env["DEBUG"] === "true") { - console.log(`*** INVALID: ${source.name}: ${title} ${link}`); - } + debug(`*** INVALID: ${source.name}: ${title} ${link}`); } else { articles.push(article); - if (process.env["DEBUG"] === "true") { - console.log(`*** VALID: ${source.name}: ${title} ${link}`); - } + debug(`*** VALID: ${source.name}: ${title} ${link}`); } } else { - if (process.env["DEBUG"] === "true") { - console.log( - `*** MISSING INFO: ${source.name}: ${title} ${relativeLink}`, - ); - } + debug(`*** MISSING INFO: ${source.name}: ${title} ${relativeLink}`); } }); - if (process.env["DEBUG"] === "true") { - console.log(`*** Fetched ${articles.length} articles from: ${source.name}`); - } + debug(`*** Fetched ${articles.length} articles from: ${source.name}`); return articles; }; -// Fetch articles from all sources const fetchAllArticles = async (): Promise => { const allArticles: Article[] = []; for (const source of newsSources) { - if (process.env["DEBUG"] === "true") { - console.log(`*** Fetching articles from all sources`); - } const fetchedArticles = await fetchArticlesFromSource(source); allArticles.push(...fetchedArticles); } shuffle(allArticles); - if (process.env["DEBUG"] === "true") { - console.log(`*** Total articles fetched: ${allArticles.length}`); - } + log(`Total articles fetched: ${allArticles.length}`); return allArticles; }; const insertArticles = (articles: Article[]) => { - if (process.env["DEBUG"] === "true") { - console.log(`*** Inserting ${articles.length} articles into the database`); - } + log(`*** Inserting ${articles.length} articles into the database`); articles.forEach(insertArticle); }; @@ -99,11 +78,9 @@ const getCachedArticles = ( limit: number, excludedIds: string[] = [], ): Article[] => { - if (process.env["DEBUG"] === "true") { - console.log( - `*** Getting cached articles with offset: ${offset}, limit: ${limit}, excluding IDs: ${excludedIds.join(", ")}`, - ); - } + debug( + `Getting cached articles with offset: ${offset}, limit: ${limit}, excluding IDs: ${excludedIds.join(", ")}`, + ); const articles = db .prepare( "SELECT * FROM articles WHERE id NOT IN ('" + @@ -112,22 +89,17 @@ const getCachedArticles = ( ) .all(limit, offset) as Article[]; - if (process.env["DEBUG"] === "true") { - console.log(`*** Retrieved ${articles.length} cached articles`); - } + debug(`*** Retrieved ${articles.length} cached articles`); return articles; }; -const fetchAndStoreArticles = async () => { - if (process.env["DEBUG"] === "true") { - console.log(`*** Fetching and storing articles`); - } +const fetchAndStoreArticles = async (): Promise => { + debug(`Fetching and storing articles`); const allArticles = await fetchAllArticles(); - insertArticles(allArticles); - if (process.env["DEBUG"] === "true") { - console.log(`*** Articles fetched and stored successfully`); - } + const insertedArticles = allArticles.filter(insertArticle); + + return insertedArticles; }; const isCacheValid = (): boolean => { @@ -141,18 +113,12 @@ const isCacheValid = (): boolean => { const hoursDifference = (now.getTime() - articleDate.getTime()) / (1000 * 60 * 60); - if (process.env["DEBUG"] === "true") { - console.log( - `*** Cache validity checked. Hours difference: ${hoursDifference}`, - ); - } + debug(`Cache validity checked. Hours difference: ${hoursDifference}`); return hoursDifference < 1; } - if (process.env["DEBUG"] === "true") { - console.log(`*** No articles in cache`); - } + debug(`No articles in cache`); return false; }; diff --git a/src/util/articleUtils.ts b/src/util/articleUtils.ts index def6397..38bc79b 100644 --- a/src/util/articleUtils.ts +++ b/src/util/articleUtils.ts @@ -1,6 +1,7 @@ import db from "./db"; import { Article } from "../types"; import articleSchema from "./articleSchema"; +import { debug, log } from "./log"; const isValidArticle = (article: Article) => { try { @@ -8,15 +9,13 @@ const isValidArticle = (article: Article) => { return true; } catch (e) { if (process.env["DEBUG"] === "true") { - console.log( - `*** INVALID: ${article.source}: ${article.title} - ${e.errors.map((err: any) => err.message).join(", ")}`, - ); + debug(`INVALID: ${article.source}: ${article.title} - ${e}`); } return false; } }; -const insertArticle = (article: Article) => { +const insertArticle = (article: Article): boolean => { const insert = db.prepare( "INSERT INTO articles (id, title, link, source, created_at) VALUES (?, ?, ?, ?, ?)", ); @@ -35,15 +34,14 @@ const insertArticle = (article: Article) => { article.source, new Date().toISOString(), ); + return true; } catch (error) { - if (process.env["DEBUG"] === "true") { - console.log(`*** ERROR: ${error.message}`); - } + debug(`ERROR: ${error}`); + return false; } } else { - if (process.env["DEBUG"] === "true") { - console.log(`*** DUPLICATE: ${article.title}`); - } + debug(`DUPLICATE: ${article.title}`); + return false; } }; diff --git a/src/util/log.ts b/src/util/log.ts new file mode 100644 index 0000000..351e0e3 --- /dev/null +++ b/src/util/log.ts @@ -0,0 +1,9 @@ +export function debug(...args: any[]): void { + if (process.env["DEBUG"] === "true") { + console.log("$", ...args); + } +} + +export function log(...args: any[]): void { + console.log(">>", ...args); +} diff --git a/src/util/sendTelegramMessage.ts b/src/util/sendTelegramMessage.ts new file mode 100644 index 0000000..bfb028c --- /dev/null +++ b/src/util/sendTelegramMessage.ts @@ -0,0 +1,32 @@ +const sendTelegramMessage = async (message: string) => { + const botToken = process.env.TELEGRAM_BOT_TOKEN; + const chatId = process.env.TELEGRAM_CHAT_ID; + const url = `https://api.telegram.org/bot${botToken}/sendMessage`; + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + chat_id: chatId, + text: message, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error( + `Error sending message: ${response.statusText}, ${JSON.stringify(data)}`, + ); + } + + console.log("Message sent successfully."); + } catch (error) { + console.error("Error sending Telegram message:", error); + } +}; + +export default sendTelegramMessage; diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..49d0d9e --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,38 @@ +import { fetchAndStoreArticles } from "./util/api"; +import sendTelegramMessage from "./util/sendTelegramMessage"; +import { newsSources } from "./util/newsSources"; +import { log } from "./util/log"; + +const scheduleArticleUpdate = async () => { + try { + const articles = await fetchAndStoreArticles(); + const successMessage = generateSuccessMessage(articles); + log(successMessage); + if (articles.length > 0) await sendTelegramMessage(successMessage); + } catch (error) { + const errorMessage = `Error fetching articles: ${JSON.stringify(error, null, 2)}`; + log(errorMessage); + await sendTelegramMessage(errorMessage); + } +}; + +const generateSuccessMessage = (articles: any[]) => { + const counts = articles.reduce( + (acc: Record, article: any) => { + acc[article.source] = (acc[article.source] || 0) + 1; + return acc; + }, + {}, + ); + + const articleCounts = newsSources + .map((source) => `${source.name}: ${counts[source.name] || 0}`) + .join("\n"); + + return `Articles fetched and stored successfully.\n\n${articleCounts}\n\nVisit: https://hyperwave.codes`; +}; + +// Run import every 15 minutes, unless someone gets mad +setInterval(scheduleArticleUpdate, 1000 * 60 * 15); + +scheduleArticleUpdate(); diff --git a/tsconfig.json b/tsconfig.json index b3d85fe..afe8b7f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,11 @@ "noEmit": true, "types": ["bun-types"], "esModuleInterop": true, - "moduleResolution": "node" - } + "moduleResolution": "node", + "target": "ES2021", + "strict": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] }