diff --git a/.github/workflows/cron.disable.yml b/.github/workflows/cron.disable.yml index bb5e86e..80bd089 100644 --- a/.github/workflows/cron.disable.yml +++ b/.github/workflows/cron.disable.yml @@ -45,6 +45,7 @@ jobs: APIKEY: ${{ secrets.APIKEY }} SENDFROM: ${{ secrets.SENDFROM }} SENDTO: ${{ secrets.SENDTO }} + TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_WEBHOOK_URL }} MFA_SECRET: ${{ secrets.MFA_SECRET }} - name: Upload state diff --git a/README.md b/README.md index 310493d..ddb65d0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ WebClassから取得した課題を任意のTeamsチャネルで通知します 主に高専の人向け 1. WebClassスクレイピングで課題を取得 & Microsoftアカウントログイン -2. 整形してResendからメールを送信 +2. 整形してTeams Webhookへ送信 3. teamsに通知が行く 簡単にこんな流れです。 @@ -15,17 +15,14 @@ WebClassから取得した課題を任意のTeamsチャネルで通知します - Ubuntu 22.04.5 LTS - Node v22.15.0 - pupperteer 24.34.0 - - resend 6.7.0 ## setup 0. ワークフローの作成 -1. チャネルのメールアドレス取得 -2. ドメインの取得 -3. Resendに登録 -4. 環境構築 -5. OTP認証 -6. 確認 +1. Webhook URLの取得 +2. 環境構築 +3. OTP認証 +4. 確認 0が結構くせもので、Microsoftは組織が強いのでもしかしたら登録できない可能性は高いです。高専機構ではいけるかも @@ -57,44 +54,15 @@ WebClassから取得した課題を任意のTeamsチャネルで通知します 組織で制限されてたらここまで来れない、ワークフロー作れなそうなら諦める -### 1.メールアドレスの作成 +### 1.Webhook URLの取得 -最初の画像のところにあるメールアドレスを取得、< >で囲われたメールアドレスをコピーしておく +最初の画像のところにあるWebhook URLを取得し、コピーしておく -詳細から一応ドメインから絞れるけど一旦「だれでも」にしとく +Webhookの受信設定は必要に応じて制限してください ![alt text](img/image6.png) -### 2.ドメインの取得 - -このプロジェクトではプログラムからAPIを通してメール送るので独自ドメインから送信した方が楽です - -GmailやoutlookとかのドメインからはOAUTHなどでAPIが複雑になりそうです。あとドメインのほうがかっこいい - -どこでもいいですが、cloudflareから取るのが安くておすすめです - -### 3.Resendに登録 - -さっきからResend言ってますが、主に開発者のためのメール配信APIのことです。 -コードを数行書くだけでメールが送信できます。 - -https://resend.com/ - -サインアップした後、ドメインも登録します。ここでは説明しません。 - -もしDNSをcloudflare使っているのであれば簡単にDNSに登録することができます。 -右にあるSign in to Cloudflareをクリックしてログインするだけです。 - -**必ずDMARCも登録する必要があります!** - -cloudflareログインではDMARCレコードが自動で作成されないので手動で登録します - -DMARCを登録しないと普通迷惑メールに振り分けられるようで、その場合は正常に通知されません - -![alt text](img/image7.png) - - -### 4.環境構築 +### 2.環境構築 パッケージをインストールします。aptを使ってます。 @@ -120,6 +88,7 @@ $ npm install cat << EOF > .env USER_ID='{your_ID}' PASSWORD='{your_password}' +TEAMS_WEBHOOK_URL='{teams_webhook_url}' APIKEY='{Resend_APIKEY}' SENDFROM='notification@{your_domain}' SENDTO='{channel_mailaddress}' @@ -131,9 +100,10 @@ EOF ``` USER_ID='{your_ID}' PASSWORD='{your_password}' -APIKEY="{Resend_APIKEY}" -SENDFROM="notification@{your_domain}" -SENDTO="{channel_mailaddress}" +TEAMS_WEBHOOK_URL='{teams_webhook_url}' +APIKEY='{Resend_APIKEY}' +SENDFROM='notification@{your_domain}' +SENDTO='{channel_mailaddress}' MFA_SECRET='{MFA_SECRET}' ``` @@ -141,16 +111,14 @@ MFA_SECRET='{MFA_SECRET}' | -------- | ---------------------------------------------------------- | | USER_ID | Microsoftアカウントのメールアドレス | | PASSWORD | Microsoftアカウントのパスワード | -| APIKEY | ResendのAPIKEY | -| SENDFROM | ドメインのメールアドレス | -| SENDTO | チャネルのメールアドレス | +| TEAMS_WEBHOOK_URL | Teamsで発行したWebhook URL | +| APIKEY | ResendのAPIKEY(エラー時の管理者メール通知も使う場合) | +| SENDFROM | 送信元メールアドレス(エラー時の管理者メール通知も使う場合)| +| SENDTO | 管理者通知先メールアドレス(エラー時の管理者メール通知) | | MFA_SECRET | 後述するシークレット | -SENDFROMのnotificationの部分はなんでもいいです - - -### 5. OTP認証 +### 3. OTP認証 Microsoft のOTP認証を通します @@ -170,7 +138,7 @@ Google Authenticator などの認証アプリを使用してこのQRコードを ここで注意するのが、Microsoft Authenticator はこのプログラム内での認証とは違います。必ず**別の認証アプリを設定する**をクリックしてください! -### 6. 確認 +### 4. 確認 `$ node --env-file=.env index.js` @@ -180,14 +148,7 @@ cookieが切れる or `cookies.json`が存在しない(初回実行) の場合 ### 補足 -teamsのフックに直接メールを送信する方法について、 -独自ドメインからのメールはスパム対策に厳しく、簡単にブロックされる可能性があります(実際に僕は一度ブロックされました) - -また受取失敗や、スパム判定で蹴られた際に状況が分からないなどいろいろ不便なことがあります。 - -回避策としてoutlookやGmailに一度送信し、そこから転送設定でチャネルメールアドレスに転送する方法があります - -必要に応じて対応してみてください +Teams Webhook経由で通知するため、メール転送の設定は不要です。 #### 追記 - 結局ローカルサーバでcrontabする方法で落ち着きました。半月ほど動かしましたが、正常に機能しています。 diff --git a/debug_sender.js b/debug_sender.js index 7e320e8..0d331e9 100644 --- a/debug_sender.js +++ b/debug_sender.js @@ -1,45 +1,31 @@ -import { Resend } from "resend"; - async function main() { - const subject = "Test Email"; - const body = "

This is a test email from the notifier.

"; + const subject = "Test Teams Notification"; + const body = "This is a test Teams webhook notification from the notifier."; - const APIKEY = process.env.APIKEY; - if (!APIKEY) { - console.warn("APIKEY not set. Skipping email notification."); - return; - } - const sendTo = process.env.SENDTO; - if (!sendTo) { - console.warn("SENDTO not set. Skipping email notification."); + const webhookUrl = process.env.TEAMS_WEBHOOK_URL; + if (!webhookUrl) { + console.warn("TEAMS_WEBHOOK_URL not set. Skipping Teams notification."); return; } - const sendFrom = process.env.SENDFROM; - if (!sendFrom) { - console.warn("SENDFROM not set. Skipping email notification."); - return; - } - - const resend = new Resend(APIKEY); try { - console.log(`Sending email with subject: "${subject}"`); - const { data, error } = await resend.emails.send({ - from: `WebClass Notifier <${sendFrom}>`, - to: sendTo, - subject: subject, - html: body, + console.log(`Sending Teams webhook notification with subject: "${subject}"`); + const response = await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: `**${subject}**\n\n${body}`, + }), }); - if (error) { - console.error("Failed to send email:", { error }); - throw error; + if (!response.ok) { + const responseText = await response.text(); + throw new Error(`Webhook request failed with status ${response.status}: ${responseText}`); } - console.log("Email sent successfully:", { data }); - console.log("Test email sent successfully!"); + console.log("Teams webhook notification sent successfully!"); } catch (error) { - console.error("Failed to send test email:", error); + console.error("Failed to send test Teams notification:", error); } } diff --git a/index.js b/index.js index 8cfc0d8..85982a6 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,5 @@ import { scrapeAssignments } from "./src/scraper.js"; -import { sendNotification } from "./src/sender.js"; +import { sendErrorNotification, sendLoginRequiredNotification, sendNotification } from "./src/sender.js"; import { processNotifications } from "./src/notifier.js"; import fs from "fs/promises"; import path from "path"; @@ -22,7 +22,8 @@ async function run() { const scrapeResult = await scrapeAssignments(); if (scrapeResult?.loginRequired) { - console.log("Scraping failed. Skipping notification."); + console.log("Scraping failed due to authentication/session issue. Sending alert notification."); + await sendLoginRequiredNotification(scrapeResult?.error || ""); return; } @@ -37,6 +38,11 @@ async function run() { } catch (error) { console.error("An error occurred in the main process:", error); + try { + await sendErrorNotification(error); + } catch (notificationError) { + console.error("Failed to send error notifications:", notificationError); + } process.exit(1); } } diff --git a/package-lock.json b/package-lock.json index f9a2266..1a572ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,12 +4,11 @@ "requires": true, "packages": { "": { - "name": "webc-asgmt-notify", "dependencies": { "bottleneck": "^2.19.5", "dotenv": "^17.2.3", "puppeteer": "^24.34.0", - "resend": "^6.7.0" + "resend": "^6.12.4" } }, "node_modules/@babel/code-frame": { @@ -797,6 +796,12 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -890,12 +895,13 @@ } }, "node_modules/resend": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/resend/-/resend-6.7.0.tgz", - "integrity": "sha512-2ZV0NDZsh4Gh+Nd1hvluZIitmGJ59O4+OxMufymG6Y8uz1Jgt2uS1seSENnkIUlmwg7/dwmfIJC9rAufByz7wA==", + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.4.tgz", + "integrity": "sha512-lRpJ2Hxd+ht+JPDm97juRcUp9HOMuZyxaRFRFmc9Tx8iNWiei94Dx9v6SWufgKk2667C/uCeKKspMotOHSpCSg==", "license": "MIT", "dependencies": { - "svix": "1.84.1" + "postal-mime": "2.7.4", + "standardwebhooks": "1.0.0" }, "engines": { "node": ">=20" @@ -1025,16 +1031,6 @@ "node": ">=8" } }, - "node_modules/svix": { - "version": "1.84.1", - "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz", - "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==", - "license": "MIT", - "dependencies": { - "standardwebhooks": "1.0.0", - "uuid": "^10.0.0" - } - }, "node_modules/tar-fs": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", @@ -1088,19 +1084,6 @@ "license": "MIT", "optional": true }, - "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/webdriver-bidi-protocol": { "version": "0.3.10", "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.10.tgz", diff --git a/package.json b/package.json index cee4ede..7dd34b6 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,6 @@ "bottleneck": "^2.19.5", "dotenv": "^17.2.3", "puppeteer": "^24.34.0", - "resend": "^6.7.0" + "resend": "^6.12.4" } } diff --git a/src/config.js b/src/config.js index 1b9b522..3de1542 100644 --- a/src/config.js +++ b/src/config.js @@ -28,6 +28,7 @@ export default { apikey: process.env.APIKEY, sendfrom: process.env.SENDFROM, sendto: process.env.SENDTO, + teamsWebhookUrl: process.env.TEAMS_WEBHOOK_URL, baseUrl: 'https://webclass.kosen-k.go.jp/webclass/', entryUrl: 'https://webclass.kosen-k.go.jp/webclass/index.php', }; diff --git a/src/sender.js b/src/sender.js index 536b070..92ac810 100644 --- a/src/sender.js +++ b/src/sender.js @@ -1,46 +1,63 @@ import { Resend } from "resend"; import config from "./config.js"; -export async function sendNotification(subject, body) { - const { apikey, sendto, sendfrom } = config; - if (!apikey) { - console.warn("APIKEY not set. Skipping email notification."); - return; +async function sendTeamsWebhook(subject, body) { + const { teamsWebhookUrl } = config; + if (!teamsWebhookUrl) { + throw new Error("TEAMS_WEBHOOK_URL not set."); } - if (!sendto) { - console.warn("SENDTO not set. Skipping email notification."); - return; + + console.log(`Sending Teams webhook notification with subject: "${subject}"`); + const response = await fetch(teamsWebhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: `**${subject}**\n\n${body}`, + }), + }); + + if (!response.ok) { + const responseText = await response.text(); + throw new Error(`Webhook request failed with status ${response.status}: ${responseText}`); } - const resend = new Resend(apikey); + console.log("Teams webhook notification sent successfully."); +} + +async function sendAdminEmail(subject, body) { + const { apikey, sendto, sendfrom } = config; + if (!apikey || !sendto || !sendfrom) { + throw new Error("APIKEY/SENDFROM/SENDTO not set."); + } - console.log(`Sending email with subject: "${subject}"`); + const resend = new Resend(apikey); + console.log(`Sending admin email notification with subject: "${subject}"`); const { data, error } = await resend.emails.send({ from: `WebClass Notifier <${sendfrom}>`, to: sendto, - subject: subject, + subject, html: body, }); if (error) { - console.error("Failed to send email:", { error }); - throw error; // Re-throw the error to be caught by the caller + throw error; } - console.log("Email sent successfully:", { data }); + console.log("Admin email notification sent successfully:", { data }); } -export async function sendLoginRequiredNotification() { - const { apikey, username } = config; - - if (!apikey || !username) { - console.warn( - "APIKEY or USER_ID not set. Skipping login required notification." - ); - return; +export async function sendNotification(subject, body) { + try { + await sendTeamsWebhook(subject, body); + } catch (error) { + console.error("Failed to send Teams webhook notification:", error.message); + throw error; } +} + +export async function sendLoginRequiredNotification(errorMessage = "") { + const { username } = config; - const resend = new Resend(apikey); const subject = "WebClass Scraper: Authentication Required"; const body = `

Authentication Required

@@ -48,21 +65,46 @@ export async function sendLoginRequiredNotification() {

Please run the script manually in your terminal to enter the MFA code:

node --env-file=.env src/scraper.js

This is a notification for the user: ${username}

+ ${errorMessage ? `

Error detail: ${errorMessage}

` : ""} `; - console.log(`Sending authentication required email to ${username}...`); - const { data, error } = await resend.emails.send({ - from: "Scraper Alert ", - to: username, - subject: subject, - html: body, - }); + let sent = false; + const errors = []; - if (error) { - console.error("Failed to send login required email:", { error }); - throw error; + try { + await sendTeamsWebhook(subject, body); + sent = true; + } catch (error) { + console.error("Failed to send login required notification to Teams:", error.message); + errors.push(error); } - console.log("Login required email sent successfully:", { data }); + try { + await sendAdminEmail(subject, body); + sent = true; + } catch (error) { + console.error("Failed to send login required admin email notification:", error.message); + errors.push(error); + } + + if (!sent && errors.length > 0) { + throw errors[0]; + } } +export async function sendErrorNotification(error) { + const errorText = error instanceof Error ? `${error.message}\n\n${error.stack || ""}` : String(error); + const subject = "WebClass Scraper: Runtime Error"; + const body = ` +

Runtime Error

+

An error occurred in the main process.

+
${errorText}
+ `; + + try { + await sendAdminEmail(subject, body); + } catch (sendError) { + console.error("Failed to send runtime error admin email notification:", sendError.message); + throw sendError; + } +}