From 1e4843d55665f8d244d3264f422c20f6d26aed15 Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sat, 22 Aug 2026 02:59:44 +0800 Subject: [PATCH 01/19] feat: add anonymous gallery link checker --- scripts/check-gallery-links.mjs | 178 ++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 scripts/check-gallery-links.mjs diff --git a/scripts/check-gallery-links.mjs b/scripts/check-gallery-links.mjs new file mode 100644 index 0000000..839906f --- /dev/null +++ b/scripts/check-gallery-links.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node + +import { readdir, readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "data", "gallery"); +const TIMEOUT_MS = 10_000; + +export function extractGoogleDriveFolderId(value) { + try { + const url = new URL(value); + if (url.protocol !== "https:") return null; + if (!["drive.google.com", "www.drive.google.com"].includes(url.hostname)) return null; + const match = url.pathname.match(/^\/drive\/folders\/([^/]+)\/?$/); + return match?.[1] || null; + } catch { + return null; + } +} + +export function classifyGoogleDriveResponse(response, html = "") { + const finalUrl = response?.url || ""; + const lowerUrl = finalUrl.toLowerCase(); + const lowerHtml = String(html).toLowerCase(); + const permissionMarkers = [ + "request access", + "you need access", + "ask for access", + "sign in to continue", + "permission denied", + "access denied", + "drive.google.com/drive/u/0/my-drive", + ]; + + if (lowerUrl.includes("accounts.google.com")) { + return { status: "permission", reason: "Google redirected to a sign-in page" }; + } + if (permissionMarkers.some((marker) => lowerUrl.includes(marker) || lowerHtml.includes(marker))) { + return { status: "permission", reason: "Google Drive reports that access is restricted" }; + } + + const drivePage = lowerUrl.includes("drive.google.com/drive/folders/"); + const contentMarkers = [ + "drive-viewer-content", + "drive-viewer-list", + "drive.google.com/drive/folders/", + "application/vnd.google-apps.folder", + "data-id=\"folder", + "folderview", + ]; + const hasFolderContent = contentMarkers.some((marker) => lowerHtml.includes(marker)); + if (drivePage && hasFolderContent) return { status: "accessible" }; + return { status: "unknown", reason: "The response did not contain recognizable Google Drive folder content" }; +} + +function invalidResult(url, reason) { + return { status: "invalid", url, finalUrl: url, reason: `Invalid Google Drive URL: ${reason}` }; +} + +export async function checkGoogleDriveFolder(url, options = {}) { + const originalUrl = String(url); + let parsed; + try { + parsed = new URL(originalUrl); + } catch { + return invalidResult(originalUrl, "URL could not be parsed"); + } + if (parsed.protocol !== "https:") return invalidResult(originalUrl, "protocol must be HTTPS"); + if (!["drive.google.com", "www.drive.google.com"].includes(parsed.hostname)) { + return invalidResult(originalUrl, "host must be drive.google.com"); + } + if (!extractGoogleDriveFolderId(originalUrl)) { + return invalidResult(originalUrl, "path must match /drive/folders/"); + } + + const fetcher = options.fetch ?? fetch; + let lastError; + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const response = await fetcher(originalUrl, { + redirect: "follow", + signal: AbortSignal.timeout(options.timeout ?? TIMEOUT_MS), + }); + const html = await response.text(); + const classification = classifyGoogleDriveResponse(response, html); + return { ...classification, url: originalUrl, finalUrl: response.url || originalUrl }; + } catch (error) { + lastError = error; + if (attempt === 0) continue; + } + } + const timedOut = lastError?.name === "TimeoutError" || lastError?.name === "AbortError"; + return { + status: "network", + url: originalUrl, + finalUrl: originalUrl, + reason: timedOut ? "Request timed out after two attempts" : "Network request failed after two attempts", + error: lastError?.message, + }; +} + +export async function loadGalleryLinks(directory = DEFAULT_DIRECTORY) { + const entries = (await readdir(directory, { withFileTypes: true })) + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .sort((a, b) => a.name.localeCompare(b.name)); + const records = []; + for (const entry of entries) { + const filename = entry.name; + try { + const record = JSON.parse(await readFile(join(directory, filename), "utf8")); + if (typeof record.gdrive_url === "string" && record.gdrive_url.trim()) { + records.push({ url: record.gdrive_url.trim(), name: record.name || filename, date: record.date || "", filename }); + } + } catch (error) { + records.push({ invalidRecord: true, filename, error: `Invalid gallery JSON: ${error.message}` }); + } + } + return records; +} + +function usage() { + return "Usage: yarn node scripts/check-gallery-links.mjs [--url ]"; +} + +function label(result) { + if (result.status === "accessible") return "✅ Accessible"; + if (result.status === "permission") return "❌ Permission denied"; + if (result.status === "invalid") return "❌ Invalid Google Drive URL"; + if (result.status === "network") return result.reason?.toLowerCase().includes("timed out") ? "❌ Timeout" : "❌ Network error"; + return "⚠️ Unable to determine accessibility"; +} + +export async function main(argv = process.argv.slice(2)) { + if (argv.includes("--help") || argv.includes("-h")) { + console.log(`${usage()}\n\nChecks Google Drive gallery folders anonymously.`); + return 0; + } + const urlIndex = argv.indexOf("--url"); + let items; + if (urlIndex !== -1) { + if (!argv[urlIndex + 1]) { + console.error("--url requires a URL"); + return 1; + } + items = [{ url: argv[urlIndex + 1], name: "Provided URL", date: "" }]; + } else { + items = await loadGalleryLinks(); + } + + console.log("Google Drive Gallery Accessibility Check"); + const results = []; + for (const item of items) { + if (item.invalidRecord) { + console.log(`${item.filename}: ❌ Invalid gallery record (${item.error})`); + results.push({ status: "invalid" }); + continue; + } + const result = await checkGoogleDriveFolder(item.url); + results.push(result); + const suffix = result.reason ? ` — ${result.reason}` : ""; + console.log(`${item.name}${item.date ? ` (${item.date})` : ""}: ${label(result)}${suffix}`); + if (result.finalUrl && result.finalUrl !== result.url) console.log(` Final URL: ${result.finalUrl}`); + } + const passed = results.filter((result) => result.status === "accessible").length; + const failed = results.length - passed; + console.log(`Checked: ${results.length}`); + console.log(`Passed: ${passed}`); + console.log(`Failed: ${failed}`); + return failed ? 1 : 0; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().then((code) => { process.exitCode = code; }).catch((error) => { + console.error(`Gallery link checker failed: ${error.message}`); + process.exitCode = 1; + }); +} From d98bc1cb534dcb06cf2502b14599ce10d8de1347 Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sat, 22 Aug 2026 03:05:37 +0800 Subject: [PATCH 02/19] fix: tighten gallery link classification retries --- scripts/check-gallery-links.mjs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/scripts/check-gallery-links.mjs b/scripts/check-gallery-links.mjs index 839906f..c1af947 100644 --- a/scripts/check-gallery-links.mjs +++ b/scripts/check-gallery-links.mjs @@ -33,7 +33,19 @@ export function classifyGoogleDriveResponse(response, html = "") { "drive.google.com/drive/u/0/my-drive", ]; - if (lowerUrl.includes("accounts.google.com")) { + const finalUrlObject = (() => { + try { + return new URL(finalUrl); + } catch { + return null; + } + })(); + const isDriveLoginEndpoint = + finalUrlObject && + ["drive.google.com", "www.drive.google.com"].includes(finalUrlObject.hostname) && + /\/(?:servicelogin|signin|login)(?:[/?#]|$)/i.test(finalUrlObject.pathname + finalUrlObject.search); + + if (lowerUrl.includes("accounts.google.com") || isDriveLoginEndpoint) { return { status: "permission", reason: "Google redirected to a sign-in page" }; } if (permissionMarkers.some((marker) => lowerUrl.includes(marker) || lowerHtml.includes(marker))) { @@ -82,12 +94,24 @@ export async function checkGoogleDriveFolder(url, options = {}) { redirect: "follow", signal: AbortSignal.timeout(options.timeout ?? TIMEOUT_MS), }); - const html = await response.text(); + let html; + try { + html = await response.text(); + } catch (error) { + return { + status: "network", + url: originalUrl, + finalUrl: response.url || originalUrl, + reason: "Failed to read Google Drive response body", + error: error?.message, + }; + } const classification = classifyGoogleDriveResponse(response, html); return { ...classification, url: originalUrl, finalUrl: response.url || originalUrl }; } catch (error) { lastError = error; - if (attempt === 0) continue; + const retryable = error?.name === "TimeoutError" || error?.name === "AbortError" || error?.name === "TypeError"; + if (!retryable || attempt === 1) break; } } const timedOut = lastError?.name === "TimeoutError" || lastError?.name === "AbortError"; From d556fe18119397af3132f8c85e271474115e9516 Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sat, 22 Aug 2026 03:12:10 +0800 Subject: [PATCH 03/19] fix: narrow gallery link retries --- scripts/check-gallery-links.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/check-gallery-links.mjs b/scripts/check-gallery-links.mjs index c1af947..701846e 100644 --- a/scripts/check-gallery-links.mjs +++ b/scripts/check-gallery-links.mjs @@ -70,6 +70,14 @@ function invalidResult(url, reason) { return { status: "invalid", url, finalUrl: url, reason: `Invalid Google Drive URL: ${reason}` }; } +function isRetryableNetworkError(error) { + if (!error) return false; + if (error.name === "TimeoutError" || error.name === "AbortError") return true; + if (error.name !== "TypeError") return ["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT"].includes(error.code); + const message = `${error.message || ""} ${error.cause?.message || ""}`.toLowerCase(); + return /fetch failed|network|socket|econnreset|econnrefused|enotfound|eai_again|etimedout|timed out|dns/.test(message); +} + export async function checkGoogleDriveFolder(url, options = {}) { const originalUrl = String(url); let parsed; @@ -110,7 +118,7 @@ export async function checkGoogleDriveFolder(url, options = {}) { return { ...classification, url: originalUrl, finalUrl: response.url || originalUrl }; } catch (error) { lastError = error; - const retryable = error?.name === "TimeoutError" || error?.name === "AbortError" || error?.name === "TypeError"; + const retryable = isRetryableNetworkError(error); if (!retryable || attempt === 1) break; } } From 50793e9e92725787c2607c6e96fdcfe1d969ce19 Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sat, 22 Aug 2026 03:15:55 +0800 Subject: [PATCH 04/19] ci: check gallery links anonymously --- .github/workflows/gallery_check.yml | 25 +++++++++++++++++++++++++ package.json | 3 ++- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/gallery_check.yml diff --git a/.github/workflows/gallery_check.yml b/.github/workflows/gallery_check.yml new file mode 100644 index 0000000..4aece6a --- /dev/null +++ b/.github/workflows/gallery_check.yml @@ -0,0 +1,25 @@ +name: Gallery link check + +on: + workflow_dispatch: + pull_request: + paths: + - "src/data/gallery/**" + - "scripts/check-gallery-links.mjs" + push: + branches: + - master + paths: + - "src/data/gallery/**" + - "scripts/check-gallery-links.mjs" + +jobs: + gallery-links: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: "22" + - run: yarn + - run: yarn test:gallery-links diff --git a/package.json b/package.json index c6808e8..c084a2a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "develop": "gatsby develop", "dev": "gatsby develop -H 0.0.0.0", "build": "gatsby build", - "prettier": "prettier --write ." + "prettier": "prettier --write .", + "test:gallery-links": "node scripts/check-gallery-links.mjs" }, "engines": { "node": ">=22", From 537c3cd8f5f27f2b2c493b448dbee9c5ba191788 Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sat, 22 Aug 2026 03:18:04 +0800 Subject: [PATCH 05/19] docs: describe gallery link accessibility check --- .../task-3-report.md | 25 +++++++++++++++++++ README.md | 11 ++++++++ 2 files changed, 36 insertions(+) create mode 100644 .superpowers/sdd/2026-08-22-gallery-link-check/task-3-report.md diff --git a/.superpowers/sdd/2026-08-22-gallery-link-check/task-3-report.md b/.superpowers/sdd/2026-08-22-gallery-link-check/task-3-report.md new file mode 100644 index 0000000..d956edb --- /dev/null +++ b/.superpowers/sdd/2026-08-22-gallery-link-check/task-3-report.md @@ -0,0 +1,25 @@ +# Task 3 Report + +## 改動 + +- 在 `/Users/poterpan/Documents/iOSClub/Website2022/.claude/worktrees/agent-a1bf07ab2ae0df42b/README.md` 新增 `Gallery Link Check` 區段。 +- 說明掃描 `src/data/gallery` 中非空的 `gdrive_url`,以匿名且不使用 Google credential 的方式檢查,並列出一般與 `--url` debug 命令。 +- 說明 permission、invalid URL、network、indeterminate 結果會以 non-zero exit code 結束。 + +## Commit + +將 `README.md` 與本報告提交於 commit(完成驗證記錄後建立)。 + +## 命令實際結果 + +- `yarn prettier`:失敗,exit code 127;`prettier: command not found`。 +- `yarn test:gallery-links`:失敗,exit code 1;`Command "test:gallery-links" not found`。 +- `yarn build`:失敗,exit code 127;`gatsby: command not found`。 + +## Scope 檢查 + +`git status --short --untracked-files=all` 僅顯示 `README.md` 修改;沒有 `public/`、gallery JSON、credential、lockfile、UI 或 routing 檔案變更。 + +## Concerns + +目前工作樹沒有可執行的 `test:gallery-links` script,且未安裝 `prettier`、`gatsby` 依賴,因此無法完成 brief 要求的格式化、gallery checker、build 或 commit。未修改 script、package、workflow,也未弱化任何 checker 行為。 diff --git a/README.md b/README.md index a72a63c..dff708a 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,17 @@ yarn prettier 目前大部分的照片都放在 GitHub 的 FCU-iOSClub/Website2022ImageBed 上。 +### Gallery Link Check + +掃描 `src/data/gallery` 中所有非空的 `gdrive_url`,以未登入、未使用任何 Google credential 的訪客身分檢查 Google Drive 資料夾是否可存取: + +```bash +yarn test:gallery-links +yarn test:gallery-links --url "https://drive.google.com/drive/folders/" +``` + +若結果確認需要權限、網址無效、發生網路錯誤或無法判定,指令會以 non-zero exit code 結束。 + ### 競賽得獎 在 `/src/data/contest` 中新增檔案,檔案名稱以年份命名。 From dc875c2e4b017ba7959b3b555ac2e028210f6203 Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sat, 22 Aug 2026 03:25:07 +0800 Subject: [PATCH 06/19] chore: keep task report out of production diff --- .../task-3-report.md | 25 ------------------- 1 file changed, 25 deletions(-) delete mode 100644 .superpowers/sdd/2026-08-22-gallery-link-check/task-3-report.md diff --git a/.superpowers/sdd/2026-08-22-gallery-link-check/task-3-report.md b/.superpowers/sdd/2026-08-22-gallery-link-check/task-3-report.md deleted file mode 100644 index d956edb..0000000 --- a/.superpowers/sdd/2026-08-22-gallery-link-check/task-3-report.md +++ /dev/null @@ -1,25 +0,0 @@ -# Task 3 Report - -## 改動 - -- 在 `/Users/poterpan/Documents/iOSClub/Website2022/.claude/worktrees/agent-a1bf07ab2ae0df42b/README.md` 新增 `Gallery Link Check` 區段。 -- 說明掃描 `src/data/gallery` 中非空的 `gdrive_url`,以匿名且不使用 Google credential 的方式檢查,並列出一般與 `--url` debug 命令。 -- 說明 permission、invalid URL、network、indeterminate 結果會以 non-zero exit code 結束。 - -## Commit - -將 `README.md` 與本報告提交於 commit(完成驗證記錄後建立)。 - -## 命令實際結果 - -- `yarn prettier`:失敗,exit code 127;`prettier: command not found`。 -- `yarn test:gallery-links`:失敗,exit code 1;`Command "test:gallery-links" not found`。 -- `yarn build`:失敗,exit code 127;`gatsby: command not found`。 - -## Scope 檢查 - -`git status --short --untracked-files=all` 僅顯示 `README.md` 修改;沒有 `public/`、gallery JSON、credential、lockfile、UI 或 routing 檔案變更。 - -## Concerns - -目前工作樹沒有可執行的 `test:gallery-links` script,且未安裝 `prettier`、`gatsby` 依賴,因此無法完成 brief 要求的格式化、gallery checker、build 或 commit。未修改 script、package、workflow,也未弱化任何 checker 行為。 From 39eabb0ec86773152fcf824e0d59f7f4606638f3 Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sat, 22 Aug 2026 03:28:43 +0800 Subject: [PATCH 07/19] style: format gallery link checker --- scripts/check-gallery-links.mjs | 144 +++++++++++++++++++++++++------- 1 file changed, 112 insertions(+), 32 deletions(-) diff --git a/scripts/check-gallery-links.mjs b/scripts/check-gallery-links.mjs index 701846e..ad7498a 100644 --- a/scripts/check-gallery-links.mjs +++ b/scripts/check-gallery-links.mjs @@ -4,14 +4,21 @@ import { readdir, readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -const DEFAULT_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "data", "gallery"); +const DEFAULT_DIRECTORY = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "src", + "data", + "gallery", +); const TIMEOUT_MS = 10_000; export function extractGoogleDriveFolderId(value) { try { const url = new URL(value); if (url.protocol !== "https:") return null; - if (!["drive.google.com", "www.drive.google.com"].includes(url.hostname)) return null; + if (!["drive.google.com", "www.drive.google.com"].includes(url.hostname)) + return null; const match = url.pathname.match(/^\/drive\/folders\/([^/]+)\/?$/); return match?.[1] || null; } catch { @@ -42,14 +49,28 @@ export function classifyGoogleDriveResponse(response, html = "") { })(); const isDriveLoginEndpoint = finalUrlObject && - ["drive.google.com", "www.drive.google.com"].includes(finalUrlObject.hostname) && - /\/(?:servicelogin|signin|login)(?:[/?#]|$)/i.test(finalUrlObject.pathname + finalUrlObject.search); + ["drive.google.com", "www.drive.google.com"].includes( + finalUrlObject.hostname, + ) && + /\/(?:servicelogin|signin|login)(?:[/?#]|$)/i.test( + finalUrlObject.pathname + finalUrlObject.search, + ); if (lowerUrl.includes("accounts.google.com") || isDriveLoginEndpoint) { - return { status: "permission", reason: "Google redirected to a sign-in page" }; + return { + status: "permission", + reason: "Google redirected to a sign-in page", + }; } - if (permissionMarkers.some((marker) => lowerUrl.includes(marker) || lowerHtml.includes(marker))) { - return { status: "permission", reason: "Google Drive reports that access is restricted" }; + if ( + permissionMarkers.some( + (marker) => lowerUrl.includes(marker) || lowerHtml.includes(marker), + ) + ) { + return { + status: "permission", + reason: "Google Drive reports that access is restricted", + }; } const drivePage = lowerUrl.includes("drive.google.com/drive/folders/"); @@ -58,24 +79,45 @@ export function classifyGoogleDriveResponse(response, html = "") { "drive-viewer-list", "drive.google.com/drive/folders/", "application/vnd.google-apps.folder", - "data-id=\"folder", + 'data-id="folder', "folderview", ]; - const hasFolderContent = contentMarkers.some((marker) => lowerHtml.includes(marker)); + const hasFolderContent = contentMarkers.some((marker) => + lowerHtml.includes(marker), + ); if (drivePage && hasFolderContent) return { status: "accessible" }; - return { status: "unknown", reason: "The response did not contain recognizable Google Drive folder content" }; + return { + status: "unknown", + reason: + "The response did not contain recognizable Google Drive folder content", + }; } function invalidResult(url, reason) { - return { status: "invalid", url, finalUrl: url, reason: `Invalid Google Drive URL: ${reason}` }; + return { + status: "invalid", + url, + finalUrl: url, + reason: `Invalid Google Drive URL: ${reason}`, + }; } function isRetryableNetworkError(error) { if (!error) return false; if (error.name === "TimeoutError" || error.name === "AbortError") return true; - if (error.name !== "TypeError") return ["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT"].includes(error.code); - const message = `${error.message || ""} ${error.cause?.message || ""}`.toLowerCase(); - return /fetch failed|network|socket|econnreset|econnrefused|enotfound|eai_again|etimedout|timed out|dns/.test(message); + if (error.name !== "TypeError") + return [ + "ECONNRESET", + "ECONNREFUSED", + "ENOTFOUND", + "EAI_AGAIN", + "ETIMEDOUT", + ].includes(error.code); + const message = + `${error.message || ""} ${error.cause?.message || ""}`.toLowerCase(); + return /fetch failed|network|socket|econnreset|econnrefused|enotfound|eai_again|etimedout|timed out|dns/.test( + message, + ); } export async function checkGoogleDriveFolder(url, options = {}) { @@ -86,12 +128,16 @@ export async function checkGoogleDriveFolder(url, options = {}) { } catch { return invalidResult(originalUrl, "URL could not be parsed"); } - if (parsed.protocol !== "https:") return invalidResult(originalUrl, "protocol must be HTTPS"); + if (parsed.protocol !== "https:") + return invalidResult(originalUrl, "protocol must be HTTPS"); if (!["drive.google.com", "www.drive.google.com"].includes(parsed.hostname)) { return invalidResult(originalUrl, "host must be drive.google.com"); } if (!extractGoogleDriveFolderId(originalUrl)) { - return invalidResult(originalUrl, "path must match /drive/folders/"); + return invalidResult( + originalUrl, + "path must match /drive/folders/", + ); } const fetcher = options.fetch ?? fetch; @@ -115,19 +161,26 @@ export async function checkGoogleDriveFolder(url, options = {}) { }; } const classification = classifyGoogleDriveResponse(response, html); - return { ...classification, url: originalUrl, finalUrl: response.url || originalUrl }; + return { + ...classification, + url: originalUrl, + finalUrl: response.url || originalUrl, + }; } catch (error) { lastError = error; const retryable = isRetryableNetworkError(error); if (!retryable || attempt === 1) break; } } - const timedOut = lastError?.name === "TimeoutError" || lastError?.name === "AbortError"; + const timedOut = + lastError?.name === "TimeoutError" || lastError?.name === "AbortError"; return { status: "network", url: originalUrl, finalUrl: originalUrl, - reason: timedOut ? "Request timed out after two attempts" : "Network request failed after two attempts", + reason: timedOut + ? "Request timed out after two attempts" + : "Network request failed after two attempts", error: lastError?.message, }; } @@ -140,12 +193,23 @@ export async function loadGalleryLinks(directory = DEFAULT_DIRECTORY) { for (const entry of entries) { const filename = entry.name; try { - const record = JSON.parse(await readFile(join(directory, filename), "utf8")); + const record = JSON.parse( + await readFile(join(directory, filename), "utf8"), + ); if (typeof record.gdrive_url === "string" && record.gdrive_url.trim()) { - records.push({ url: record.gdrive_url.trim(), name: record.name || filename, date: record.date || "", filename }); + records.push({ + url: record.gdrive_url.trim(), + name: record.name || filename, + date: record.date || "", + filename, + }); } } catch (error) { - records.push({ invalidRecord: true, filename, error: `Invalid gallery JSON: ${error.message}` }); + records.push({ + invalidRecord: true, + filename, + error: `Invalid gallery JSON: ${error.message}`, + }); } } return records; @@ -159,13 +223,18 @@ function label(result) { if (result.status === "accessible") return "✅ Accessible"; if (result.status === "permission") return "❌ Permission denied"; if (result.status === "invalid") return "❌ Invalid Google Drive URL"; - if (result.status === "network") return result.reason?.toLowerCase().includes("timed out") ? "❌ Timeout" : "❌ Network error"; + if (result.status === "network") + return result.reason?.toLowerCase().includes("timed out") + ? "❌ Timeout" + : "❌ Network error"; return "⚠️ Unable to determine accessibility"; } export async function main(argv = process.argv.slice(2)) { if (argv.includes("--help") || argv.includes("-h")) { - console.log(`${usage()}\n\nChecks Google Drive gallery folders anonymously.`); + console.log( + `${usage()}\n\nChecks Google Drive gallery folders anonymously.`, + ); return 0; } const urlIndex = argv.indexOf("--url"); @@ -184,17 +253,24 @@ export async function main(argv = process.argv.slice(2)) { const results = []; for (const item of items) { if (item.invalidRecord) { - console.log(`${item.filename}: ❌ Invalid gallery record (${item.error})`); + console.log( + `${item.filename}: ❌ Invalid gallery record (${item.error})`, + ); results.push({ status: "invalid" }); continue; } const result = await checkGoogleDriveFolder(item.url); results.push(result); const suffix = result.reason ? ` — ${result.reason}` : ""; - console.log(`${item.name}${item.date ? ` (${item.date})` : ""}: ${label(result)}${suffix}`); - if (result.finalUrl && result.finalUrl !== result.url) console.log(` Final URL: ${result.finalUrl}`); + console.log( + `${item.name}${item.date ? ` (${item.date})` : ""}: ${label(result)}${suffix}`, + ); + if (result.finalUrl && result.finalUrl !== result.url) + console.log(` Final URL: ${result.finalUrl}`); } - const passed = results.filter((result) => result.status === "accessible").length; + const passed = results.filter( + (result) => result.status === "accessible", + ).length; const failed = results.length - passed; console.log(`Checked: ${results.length}`); console.log(`Passed: ${passed}`); @@ -203,8 +279,12 @@ export async function main(argv = process.argv.slice(2)) { } if (process.argv[1] === fileURLToPath(import.meta.url)) { - main().then((code) => { process.exitCode = code; }).catch((error) => { - console.error(`Gallery link checker failed: ${error.message}`); - process.exitCode = 1; - }); + main() + .then((code) => { + process.exitCode = code; + }) + .catch((error) => { + console.error(`Gallery link checker failed: ${error.message}`); + process.exitCode = 1; + }); } From 9b719e6f9fdf3eccef4c36437a75f50fe794d050 Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sat, 22 Aug 2026 03:35:54 +0800 Subject: [PATCH 08/19] fix: harden gallery link checker responses --- scripts/check-gallery-links.mjs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/scripts/check-gallery-links.mjs b/scripts/check-gallery-links.mjs index ad7498a..6d8a801 100644 --- a/scripts/check-gallery-links.mjs +++ b/scripts/check-gallery-links.mjs @@ -28,6 +28,12 @@ export function extractGoogleDriveFolderId(value) { export function classifyGoogleDriveResponse(response, html = "") { const finalUrl = response?.url || ""; + if (response && response.ok === false) { + return { + status: "unknown", + reason: `Google Drive returned HTTP ${response.status}`, + }; + } const lowerUrl = finalUrl.toLowerCase(); const lowerHtml = String(html).toLowerCase(); const permissionMarkers = [ @@ -152,6 +158,8 @@ export async function checkGoogleDriveFolder(url, options = {}) { try { html = await response.text(); } catch (error) { + lastError = error; + if (isRetryableNetworkError(error) && attempt === 0) continue; return { status: "network", url: originalUrl, @@ -196,12 +204,20 @@ export async function loadGalleryLinks(directory = DEFAULT_DIRECTORY) { const record = JSON.parse( await readFile(join(directory, filename), "utf8"), ); - if (typeof record.gdrive_url === "string" && record.gdrive_url.trim()) { + if (record.gdrive_url === null || typeof record.gdrive_url === "string") { + if (typeof record.gdrive_url === "string" && record.gdrive_url.trim()) { + records.push({ + url: record.gdrive_url.trim(), + name: record.name || filename, + date: record.date || "", + filename, + }); + } + } else { records.push({ - url: record.gdrive_url.trim(), - name: record.name || filename, - date: record.date || "", + invalidRecord: true, filename, + error: "Invalid gallery JSON: gdrive_url must be a string or null", }); } } catch (error) { From 7dfa164342e9a4f7ce78e561463c5e3455e65f67 Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sat, 22 Aug 2026 03:44:00 +0800 Subject: [PATCH 09/19] fix: preserve optional gallery link records --- scripts/check-gallery-links.mjs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/scripts/check-gallery-links.mjs b/scripts/check-gallery-links.mjs index 6d8a801..5f2ef84 100644 --- a/scripts/check-gallery-links.mjs +++ b/scripts/check-gallery-links.mjs @@ -28,12 +28,6 @@ export function extractGoogleDriveFolderId(value) { export function classifyGoogleDriveResponse(response, html = "") { const finalUrl = response?.url || ""; - if (response && response.ok === false) { - return { - status: "unknown", - reason: `Google Drive returned HTTP ${response.status}`, - }; - } const lowerUrl = finalUrl.toLowerCase(); const lowerHtml = String(html).toLowerCase(); const permissionMarkers = [ @@ -79,6 +73,13 @@ export function classifyGoogleDriveResponse(response, html = "") { }; } + if (response && response.ok === false) { + return { + status: "unknown", + reason: `Google Drive returned HTTP ${response.status}`, + }; + } + const drivePage = lowerUrl.includes("drive.google.com/drive/folders/"); const contentMarkers = [ "drive-viewer-content", @@ -204,7 +205,11 @@ export async function loadGalleryLinks(directory = DEFAULT_DIRECTORY) { const record = JSON.parse( await readFile(join(directory, filename), "utf8"), ); - if (record.gdrive_url === null || typeof record.gdrive_url === "string") { + if ( + record.gdrive_url === undefined || + record.gdrive_url === null || + typeof record.gdrive_url === "string" + ) { if (typeof record.gdrive_url === "string" && record.gdrive_url.trim()) { records.push({ url: record.gdrive_url.trim(), From cdb0eb1fecfe6a988ddb0a18044b53836dff0cd4 Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sat, 22 Aug 2026 15:20:04 +0800 Subject: [PATCH 10/19] feat: add machine-readable gallery link report --- scripts/check-gallery-links.mjs | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/scripts/check-gallery-links.mjs b/scripts/check-gallery-links.mjs index 5f2ef84..1eebfae 100644 --- a/scripts/check-gallery-links.mjs +++ b/scripts/check-gallery-links.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node -import { readdir, readFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { readdir, readFile, rename, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const DEFAULT_DIRECTORY = join( @@ -237,7 +238,24 @@ export async function loadGalleryLinks(directory = DEFAULT_DIRECTORY) { } function usage() { - return "Usage: yarn node scripts/check-gallery-links.mjs [--url ]"; + return "Usage: yarn node scripts/check-gallery-links.mjs [--url ] [--report ]"; +} + +function reportKey(item) { + return createHash("sha256").update(`${item.filename || "--url"}\0${item.url}`).digest("hex"); +} + +function reportFailure(item, result) { + return { key: reportKey(item), name: item.name || item.filename || "Provided URL", date: item.date || "", filename: item.filename || "--url", url: item.url || "", status: result.status, ...(result.reason ? { reason: result.reason } : {}) }; +} + +async function writeReport(reportPath, items, results) { + const failures = results.map((result, index) => ({ result, item: items[index] })).filter(({ result }) => result.status !== "accessible").map(({ item, result }) => reportFailure(item, result)).sort((a, b) => a.key.localeCompare(b.key)); + const report = { checked: results.length, passed: results.length - failures.length, failed: failures.length, failures }; + const destination = resolve(reportPath); + const temporary = `${destination}.tmp-${process.pid}-${Date.now()}`; + await writeFile(temporary, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + await rename(temporary, destination); } function label(result) { @@ -259,6 +277,9 @@ export async function main(argv = process.argv.slice(2)) { return 0; } const urlIndex = argv.indexOf("--url"); + const reportIndex = argv.indexOf("--report"); + if (reportIndex !== -1 && !argv[reportIndex + 1]) { console.error("--report requires a path"); return 1; } + const reportPath = reportIndex === -1 ? null : argv[reportIndex + 1]; let items; if (urlIndex !== -1) { if (!argv[urlIndex + 1]) { @@ -277,7 +298,7 @@ export async function main(argv = process.argv.slice(2)) { console.log( `${item.filename}: ❌ Invalid gallery record (${item.error})`, ); - results.push({ status: "invalid" }); + results.push({ status: "invalid", reason: "Invalid gallery record" }); continue; } const result = await checkGoogleDriveFolder(item.url); @@ -296,6 +317,7 @@ export async function main(argv = process.argv.slice(2)) { console.log(`Checked: ${results.length}`); console.log(`Passed: ${passed}`); console.log(`Failed: ${failed}`); + if (reportPath) await writeReport(reportPath, items, results); return failed ? 1 : 0; } From f258df2b0d05303311f1dd11f34302eead08277e Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sat, 22 Aug 2026 15:27:05 +0800 Subject: [PATCH 11/19] feat: notify maintainers about gallery link failures --- package.json | 3 +- scripts/notify-gallery-links.mjs | 103 +++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 scripts/notify-gallery-links.mjs diff --git a/package.json b/package.json index c084a2a..7889d83 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "dev": "gatsby develop -H 0.0.0.0", "build": "gatsby build", "prettier": "prettier --write .", - "test:gallery-links": "node scripts/check-gallery-links.mjs" + "test:gallery-links": "node scripts/check-gallery-links.mjs", + "notify-gallery-links": "node scripts/notify-gallery-links.mjs" }, "engines": { "node": ">=22", diff --git a/scripts/notify-gallery-links.mjs b/scripts/notify-gallery-links.mjs new file mode 100644 index 0000000..2a5a2a2 --- /dev/null +++ b/scripts/notify-gallery-links.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +import { readFile, writeFile, rename } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { randomUUID } from "node:crypto"; + +const TIMEOUT_MS = 10_000; +const MAX_NAMES = 10; +const ALLOWED_REASONS = new Set(["permission", "invalid"]); + +function usage() { + throw new Error("Usage: node scripts/notify-gallery-links.mjs --report --state --workflow-url "); +} + +function parseArgs(argv) { + const result = {}; + for (let i = 2; i < argv.length; i += 2) { + const flag = argv[i]; + const value = argv[i + 1]; + if (!flag?.startsWith("--") || !value || result[flag.slice(2)]) usage(); + result[flag.slice(2)] = value; + } + if (!result.report || !result.state || !result["workflow-url"] || Object.keys(result).length !== 3) usage(); + return result; +} + +function validReport(report) { + return report && typeof report === "object" && Number.isInteger(report.checked) && + Number.isInteger(report.passed) && Number.isInteger(report.failed) && Array.isArray(report.failures); +} + +function filteredFailures(report) { + if (!validReport(report)) throw new Error("Malformed report"); + return report.failures.filter((failure) => { + if (!failure || typeof failure !== "object" || typeof failure.reason !== "string") return false; + return ALLOWED_REASONS.has(failure.reason); + }).map((failure) => { + const key = typeof failure.key === "string" && failure.key ? failure.key : + `${failure.name ?? ""}|${failure.date ?? ""}|${failure.filename ?? ""}`; + return { key, name: typeof failure.name === "string" && failure.name ? failure.name : key, reason: failure.reason }; + }); +} + +function validState(state) { + return state && typeof state === "object" && (state.status === "healthy" || state.status === "failing") && + Array.isArray(state.failureKeys) && state.failureKeys.every((key) => typeof key === "string") && + Number.isInteger(state.permissionCount) && Number.isInteger(state.invalidCount); +} + +async function readJson(path) { + return JSON.parse(await readFile(path, "utf8")); +} + +async function saveState(path, state) { + const temporary = join(dirname(path), `.${stateFileName(path)}.${randomUUID()}.tmp`); + await writeFile(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600 }); + await rename(temporary, path); +} +function stateFileName(path) { return path.split("/").pop() || "state"; } + +function makePayload({ transition, failures, previous, workflowUrl }) { + const names = failures.slice(0, MAX_NAMES).map(({ name }) => name); + const omitted = failures.length - names.length; + let content; + if (transition === "recovery") { + content = `Gallery link checks recovered. Previous failures: ${previous.permissionCount} permission, ${previous.invalidCount} invalid.`; + } else { + content = `${transition === "initial" ? "Gallery link checks failing" : "Gallery link failures changed"}: ${failures.length} affected.`; + if (names.length) content += ` Affected galleries: ${names.join(", ")}${omitted ? ` (and ${omitted} more)` : ""}.`; + } + content += ` Workflow: ${workflowUrl}`; + return { content }; +} + +async function send(payload, webhook, fetchImpl = globalThis.fetch) { + if (typeof fetchImpl !== "function") throw new Error("Fetch unavailable"); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const response = await fetchImpl(webhook, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: controller.signal }); + if (!response || !response.ok) throw new Error("Discord request failed"); + } catch (error) { + if (error?.name === "AbortError") throw new Error("Discord request timed out"); + throw error instanceof Error && error.message === "Discord request failed" ? error : new Error("Discord request failed"); + } finally { clearTimeout(timer); } +} + +export async function processNotification({ report, statePath, workflowUrl, webhook = process.env.DISCORD_GALLERY_WEBHOOK_URL, fetchImpl = globalThis.fetch }) { + const failures = filteredFailures(report); + const keys = [...new Set(failures.map(({ key }) => key))].sort(); + let previous = null; + try { previous = await readJson(statePath); if (!validState(previous)) previous = null; } catch { /* corrupt/missing means no previous state */ } + const status = keys.length ? "failing" : "healthy"; + const changed = !previous || previous.status !== status || (status === "failing" && JSON.stringify(previous.failureKeys) !== JSON.stringify(keys)); + const transition = status === "healthy" ? (previous?.status === "failing" ? "recovery" : null) : (changed ? (previous ? "changed" : "initial") : null); + if (transition) { if (!webhook) throw new Error("Missing Discord webhook"); await send(makePayload({ transition, failures, previous: previous ?? { permissionCount: 0, invalidCount: 0 }, workflowUrl }), webhook, fetchImpl); } + const next = { status, failureKeys: keys, permissionCount: failures.filter((f) => f.reason === "permission").length, invalidCount: failures.filter((f) => f.reason === "invalid").length }; + await saveState(statePath, next); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + try { const args = parseArgs(process.argv); await processNotification({ report: await readJson(args.report), statePath: args.state, workflowUrl: args["workflow-url"] }); process.exitCode = 0; } + catch (error) { process.stderr.write(`${error.message}\n`); process.exitCode = 1; } +} From 628fcc1168f1e27457c0e6f95f79f8e8d3d1ddbc Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sat, 22 Aug 2026 15:33:13 +0800 Subject: [PATCH 12/19] fix: validate and bound gallery notifications --- scripts/notify-gallery-links.mjs | 35 ++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/scripts/notify-gallery-links.mjs b/scripts/notify-gallery-links.mjs index 2a5a2a2..6707f5f 100644 --- a/scripts/notify-gallery-links.mjs +++ b/scripts/notify-gallery-links.mjs @@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto"; const TIMEOUT_MS = 10_000; const MAX_NAMES = 10; +const MAX_CONTENT = 2000; const ALLOWED_REASONS = new Set(["permission", "invalid"]); function usage() { @@ -24,20 +25,28 @@ function parseArgs(argv) { } function validReport(report) { - return report && typeof report === "object" && Number.isInteger(report.checked) && - Number.isInteger(report.passed) && Number.isInteger(report.failed) && Array.isArray(report.failures); + if (!report || typeof report !== "object" || !Number.isInteger(report.checked) || + !Number.isInteger(report.passed) || !Number.isInteger(report.failed) || !Array.isArray(report.failures) || + report.checked < 0 || report.passed < 0 || report.failed < 0 || report.checked !== report.passed + report.failed || + report.failures.length !== report.failed) throw new Error("Malformed report"); + return true; } function filteredFailures(report) { - if (!validReport(report)) throw new Error("Malformed report"); - return report.failures.filter((failure) => { - if (!failure || typeof failure !== "object" || typeof failure.reason !== "string") return false; - return ALLOWED_REASONS.has(failure.reason); - }).map((failure) => { - const key = typeof failure.key === "string" && failure.key ? failure.key : - `${failure.name ?? ""}|${failure.date ?? ""}|${failure.filename ?? ""}`; - return { key, name: typeof failure.name === "string" && failure.name ? failure.name : key, reason: failure.reason }; - }); + validReport(report); + const candidates = report.failures.map((failure) => { + if (!failure || typeof failure !== "object" || + ["key", "name", "date", "filename", "url", "status", "reason"].some((field) => typeof failure[field] !== "string")) { + throw new Error("Malformed report"); + } + return failure; + }).filter((failure) => ALLOWED_REASONS.has(failure.reason)); + const byKey = new Map(); + for (const failure of candidates) { + if (!failure.key) throw new Error("Malformed report"); + if (!byKey.has(failure.key)) byKey.set(failure.key, { key: failure.key, name: failure.name || failure.key, reason: failure.reason }); + } + return [...byKey.values()]; } function validState(state) { @@ -67,8 +76,8 @@ function makePayload({ transition, failures, previous, workflowUrl }) { content = `${transition === "initial" ? "Gallery link checks failing" : "Gallery link failures changed"}: ${failures.length} affected.`; if (names.length) content += ` Affected galleries: ${names.join(", ")}${omitted ? ` (and ${omitted} more)` : ""}.`; } - content += ` Workflow: ${workflowUrl}`; - return { content }; + content += ` Workflow: ${String(workflowUrl).slice(0, 500)}`; + return { content: content.length <= MAX_CONTENT ? content : `${content.slice(0, MAX_CONTENT - 1)}…` }; } async function send(payload, webhook, fetchImpl = globalThis.fetch) { From 0b07cf159328bed774b3b7e434fef44562bb9eb6 Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sat, 22 Aug 2026 15:39:48 +0800 Subject: [PATCH 13/19] ci: add gallery PR gate and maintenance notifications --- .github/workflows/gallery_check.yml | 10 ++--- .github/workflows/gallery_notify.yml | 64 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/gallery_notify.yml diff --git a/.github/workflows/gallery_check.yml b/.github/workflows/gallery_check.yml index 4aece6a..5877a0c 100644 --- a/.github/workflows/gallery_check.yml +++ b/.github/workflows/gallery_check.yml @@ -1,17 +1,13 @@ name: Gallery link check on: - workflow_dispatch: pull_request: paths: - "src/data/gallery/**" - "scripts/check-gallery-links.mjs" - push: - branches: - - master - paths: - - "src/data/gallery/**" - - "scripts/check-gallery-links.mjs" + +permissions: + contents: read jobs: gallery-links: diff --git a/.github/workflows/gallery_notify.yml b/.github/workflows/gallery_notify.yml new file mode 100644 index 0000000..ffb98a2 --- /dev/null +++ b/.github/workflows/gallery_notify.yml @@ -0,0 +1,64 @@ +name: Gallery link maintenance + +on: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + push: + branches: + - master + paths: + - "src/data/gallery/**" + - "scripts/check-gallery-links.mjs" + - "scripts/notify-gallery-links.mjs" + - ".github/workflows/gallery_check.yml" + - ".github/workflows/gallery_notify.yml" + +permissions: + contents: read + +env: + REPORT_PATH: /tmp/gallery-link-report.json + STATE_PATH: .cache/gallery-link-state.json + +jobs: + gallery-maintenance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: "22" + - run: yarn + - name: Prepare state directory + run: mkdir -p "$(dirname "$STATE_PATH")" + - name: Restore notification state + uses: actions/cache@v4 + with: + path: ${{ env.STATE_PATH }} + key: gallery-link-state-${{ github.repository }}-${{ github.ref_name }} + restore-keys: | + gallery-link-state-${{ github.repository }}- + - name: Check gallery links + id: checker + continue-on-error: true + run: yarn test:gallery-links --report "$REPORT_PATH" + - name: Notify gallery link failures + id: notifier + if: ${{ always() }} + env: + DISCORD_GALLERY_WEBHOOK_URL: ${{ secrets.DISCORD_GALLERY_WEBHOOK_URL }} + WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: yarn notify-gallery-links --report "$REPORT_PATH" --state "$STATE_PATH" --workflow-url "$WORKFLOW_URL" + - name: Save notification state + if: ${{ always() && steps.notifier.outcome == 'success' }} + uses: actions/cache/save@v4 + with: + path: ${{ env.STATE_PATH }} + key: gallery-link-state-${{ github.repository }}-${{ github.ref_name }} + - name: Preserve maintenance result + if: ${{ always() }} + run: | + if [ "${{ steps.checker.outcome }}" != "success" ] || [ "${{ steps.notifier.outcome }}" != "success" ]; then + exit 1 + fi From edf2bd982992b925a733e840de834b32e406f72e Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sat, 22 Aug 2026 15:43:57 +0800 Subject: [PATCH 14/19] ci: isolate gallery state cache by ref --- .github/workflows/gallery_notify.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/gallery_notify.yml b/.github/workflows/gallery_notify.yml index ffb98a2..1feed13 100644 --- a/.github/workflows/gallery_notify.yml +++ b/.github/workflows/gallery_notify.yml @@ -38,7 +38,7 @@ jobs: path: ${{ env.STATE_PATH }} key: gallery-link-state-${{ github.repository }}-${{ github.ref_name }} restore-keys: | - gallery-link-state-${{ github.repository }}- + gallery-link-state-${{ github.repository }}-${{ github.ref_name }}- - name: Check gallery links id: checker continue-on-error: true From 6a6bd688b1ba2bc4317807c2f5efb89de3fc0746 Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sat, 22 Aug 2026 15:47:36 +0800 Subject: [PATCH 15/19] docs: document gallery link notifications --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index dff708a..cde661e 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,20 @@ yarn test:gallery-links --url "https://drive.google.com/drive/folders/ Date: Sat, 22 Aug 2026 15:54:08 +0800 Subject: [PATCH 16/19] style: format gallery notification files --- README.md | 2 +- scripts/check-gallery-links.mjs | 32 +++++- scripts/notify-gallery-links.mjs | 174 +++++++++++++++++++++++++------ 3 files changed, 169 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index cde661e..ea191da 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ yarn test:gallery-links --url "https://drive.google.com/drive/folders/ ({ result, item: items[index] })).filter(({ result }) => result.status !== "accessible").map(({ item, result }) => reportFailure(item, result)).sort((a, b) => a.key.localeCompare(b.key)); - const report = { checked: results.length, passed: results.length - failures.length, failed: failures.length, failures }; + const failures = results + .map((result, index) => ({ result, item: items[index] })) + .filter(({ result }) => result.status !== "accessible") + .map(({ item, result }) => reportFailure(item, result)) + .sort((a, b) => a.key.localeCompare(b.key)); + const report = { + checked: results.length, + passed: results.length - failures.length, + failed: failures.length, + failures, + }; const destination = resolve(reportPath); const temporary = `${destination}.tmp-${process.pid}-${Date.now()}`; await writeFile(temporary, `${JSON.stringify(report, null, 2)}\n`, "utf8"); @@ -278,7 +297,10 @@ export async function main(argv = process.argv.slice(2)) { } const urlIndex = argv.indexOf("--url"); const reportIndex = argv.indexOf("--report"); - if (reportIndex !== -1 && !argv[reportIndex + 1]) { console.error("--report requires a path"); return 1; } + if (reportIndex !== -1 && !argv[reportIndex + 1]) { + console.error("--report requires a path"); + return 1; + } const reportPath = reportIndex === -1 ? null : argv[reportIndex + 1]; let items; if (urlIndex !== -1) { diff --git a/scripts/notify-gallery-links.mjs b/scripts/notify-gallery-links.mjs index 6707f5f..143e8af 100644 --- a/scripts/notify-gallery-links.mjs +++ b/scripts/notify-gallery-links.mjs @@ -9,7 +9,9 @@ const MAX_CONTENT = 2000; const ALLOWED_REASONS = new Set(["permission", "invalid"]); function usage() { - throw new Error("Usage: node scripts/notify-gallery-links.mjs --report --state --workflow-url "); + throw new Error( + "Usage: node scripts/notify-gallery-links.mjs --report --state --workflow-url ", + ); } function parseArgs(argv) { @@ -20,39 +22,73 @@ function parseArgs(argv) { if (!flag?.startsWith("--") || !value || result[flag.slice(2)]) usage(); result[flag.slice(2)] = value; } - if (!result.report || !result.state || !result["workflow-url"] || Object.keys(result).length !== 3) usage(); + if ( + !result.report || + !result.state || + !result["workflow-url"] || + Object.keys(result).length !== 3 + ) + usage(); return result; } function validReport(report) { - if (!report || typeof report !== "object" || !Number.isInteger(report.checked) || - !Number.isInteger(report.passed) || !Number.isInteger(report.failed) || !Array.isArray(report.failures) || - report.checked < 0 || report.passed < 0 || report.failed < 0 || report.checked !== report.passed + report.failed || - report.failures.length !== report.failed) throw new Error("Malformed report"); + if ( + !report || + typeof report !== "object" || + !Number.isInteger(report.checked) || + !Number.isInteger(report.passed) || + !Number.isInteger(report.failed) || + !Array.isArray(report.failures) || + report.checked < 0 || + report.passed < 0 || + report.failed < 0 || + report.checked !== report.passed + report.failed || + report.failures.length !== report.failed + ) + throw new Error("Malformed report"); return true; } function filteredFailures(report) { validReport(report); - const candidates = report.failures.map((failure) => { - if (!failure || typeof failure !== "object" || - ["key", "name", "date", "filename", "url", "status", "reason"].some((field) => typeof failure[field] !== "string")) { - throw new Error("Malformed report"); - } - return failure; - }).filter((failure) => ALLOWED_REASONS.has(failure.reason)); + const candidates = report.failures + .map((failure) => { + if ( + !failure || + typeof failure !== "object" || + ["key", "name", "date", "filename", "url", "status", "reason"].some( + (field) => typeof failure[field] !== "string", + ) + ) { + throw new Error("Malformed report"); + } + return failure; + }) + .filter((failure) => ALLOWED_REASONS.has(failure.reason)); const byKey = new Map(); for (const failure of candidates) { if (!failure.key) throw new Error("Malformed report"); - if (!byKey.has(failure.key)) byKey.set(failure.key, { key: failure.key, name: failure.name || failure.key, reason: failure.reason }); + if (!byKey.has(failure.key)) + byKey.set(failure.key, { + key: failure.key, + name: failure.name || failure.key, + reason: failure.reason, + }); } return [...byKey.values()]; } function validState(state) { - return state && typeof state === "object" && (state.status === "healthy" || state.status === "failing") && - Array.isArray(state.failureKeys) && state.failureKeys.every((key) => typeof key === "string") && - Number.isInteger(state.permissionCount) && Number.isInteger(state.invalidCount); + return ( + state && + typeof state === "object" && + (state.status === "healthy" || state.status === "failing") && + Array.isArray(state.failureKeys) && + state.failureKeys.every((key) => typeof key === "string") && + Number.isInteger(state.permissionCount) && + Number.isInteger(state.invalidCount) + ); } async function readJson(path) { @@ -60,11 +96,16 @@ async function readJson(path) { } async function saveState(path, state) { - const temporary = join(dirname(path), `.${stateFileName(path)}.${randomUUID()}.tmp`); + const temporary = join( + dirname(path), + `.${stateFileName(path)}.${randomUUID()}.tmp`, + ); await writeFile(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600 }); await rename(temporary, path); } -function stateFileName(path) { return path.split("/").pop() || "state"; } +function stateFileName(path) { + return path.split("/").pop() || "state"; +} function makePayload({ transition, failures, previous, workflowUrl }) { const names = failures.slice(0, MAX_NAMES).map(({ name }) => name); @@ -74,10 +115,16 @@ function makePayload({ transition, failures, previous, workflowUrl }) { content = `Gallery link checks recovered. Previous failures: ${previous.permissionCount} permission, ${previous.invalidCount} invalid.`; } else { content = `${transition === "initial" ? "Gallery link checks failing" : "Gallery link failures changed"}: ${failures.length} affected.`; - if (names.length) content += ` Affected galleries: ${names.join(", ")}${omitted ? ` (and ${omitted} more)` : ""}.`; + if (names.length) + content += ` Affected galleries: ${names.join(", ")}${omitted ? ` (and ${omitted} more)` : ""}.`; } content += ` Workflow: ${String(workflowUrl).slice(0, 500)}`; - return { content: content.length <= MAX_CONTENT ? content : `${content.slice(0, MAX_CONTENT - 1)}…` }; + return { + content: + content.length <= MAX_CONTENT + ? content + : `${content.slice(0, MAX_CONTENT - 1)}…`, + }; } async function send(payload, webhook, fetchImpl = globalThis.fetch) { @@ -85,28 +132,89 @@ async function send(payload, webhook, fetchImpl = globalThis.fetch) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); try { - const response = await fetchImpl(webhook, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: controller.signal }); + const response = await fetchImpl(webhook, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: controller.signal, + }); if (!response || !response.ok) throw new Error("Discord request failed"); } catch (error) { - if (error?.name === "AbortError") throw new Error("Discord request timed out"); - throw error instanceof Error && error.message === "Discord request failed" ? error : new Error("Discord request failed"); - } finally { clearTimeout(timer); } + if (error?.name === "AbortError") + throw new Error("Discord request timed out"); + throw error instanceof Error && error.message === "Discord request failed" + ? error + : new Error("Discord request failed"); + } finally { + clearTimeout(timer); + } } -export async function processNotification({ report, statePath, workflowUrl, webhook = process.env.DISCORD_GALLERY_WEBHOOK_URL, fetchImpl = globalThis.fetch }) { +export async function processNotification({ + report, + statePath, + workflowUrl, + webhook = process.env.DISCORD_GALLERY_WEBHOOK_URL, + fetchImpl = globalThis.fetch, +}) { const failures = filteredFailures(report); const keys = [...new Set(failures.map(({ key }) => key))].sort(); let previous = null; - try { previous = await readJson(statePath); if (!validState(previous)) previous = null; } catch { /* corrupt/missing means no previous state */ } + try { + previous = await readJson(statePath); + if (!validState(previous)) previous = null; + } catch { + /* corrupt/missing means no previous state */ + } const status = keys.length ? "failing" : "healthy"; - const changed = !previous || previous.status !== status || (status === "failing" && JSON.stringify(previous.failureKeys) !== JSON.stringify(keys)); - const transition = status === "healthy" ? (previous?.status === "failing" ? "recovery" : null) : (changed ? (previous ? "changed" : "initial") : null); - if (transition) { if (!webhook) throw new Error("Missing Discord webhook"); await send(makePayload({ transition, failures, previous: previous ?? { permissionCount: 0, invalidCount: 0 }, workflowUrl }), webhook, fetchImpl); } - const next = { status, failureKeys: keys, permissionCount: failures.filter((f) => f.reason === "permission").length, invalidCount: failures.filter((f) => f.reason === "invalid").length }; + const changed = + !previous || + previous.status !== status || + (status === "failing" && + JSON.stringify(previous.failureKeys) !== JSON.stringify(keys)); + const transition = + status === "healthy" + ? previous?.status === "failing" + ? "recovery" + : null + : changed + ? previous + ? "changed" + : "initial" + : null; + if (transition) { + if (!webhook) throw new Error("Missing Discord webhook"); + await send( + makePayload({ + transition, + failures, + previous: previous ?? { permissionCount: 0, invalidCount: 0 }, + workflowUrl, + }), + webhook, + fetchImpl, + ); + } + const next = { + status, + failureKeys: keys, + permissionCount: failures.filter((f) => f.reason === "permission").length, + invalidCount: failures.filter((f) => f.reason === "invalid").length, + }; await saveState(statePath, next); } if (import.meta.url === `file://${process.argv[1]}`) { - try { const args = parseArgs(process.argv); await processNotification({ report: await readJson(args.report), statePath: args.state, workflowUrl: args["workflow-url"] }); process.exitCode = 0; } - catch (error) { process.stderr.write(`${error.message}\n`); process.exitCode = 1; } + try { + const args = parseArgs(process.argv); + await processNotification({ + report: await readJson(args.report), + statePath: args.state, + workflowUrl: args["workflow-url"], + }); + process.exitCode = 0; + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } } From a696920d09777e6d86b1d9111b7a5370dfc05d2d Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sat, 22 Aug 2026 16:00:08 +0800 Subject: [PATCH 17/19] fix: harden gallery notification transitions --- .github/workflows/gallery_check.yml | 3 +++ .github/workflows/gallery_notify.yml | 6 ++++-- scripts/notify-gallery-links.mjs | 8 +++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/gallery_check.yml b/.github/workflows/gallery_check.yml index 5877a0c..894634d 100644 --- a/.github/workflows/gallery_check.yml +++ b/.github/workflows/gallery_check.yml @@ -5,6 +5,9 @@ on: paths: - "src/data/gallery/**" - "scripts/check-gallery-links.mjs" + - "package.json" + - "yarn.lock" + - ".github/workflows/gallery_check.yml" permissions: contents: read diff --git a/.github/workflows/gallery_notify.yml b/.github/workflows/gallery_notify.yml index 1feed13..d857207 100644 --- a/.github/workflows/gallery_notify.yml +++ b/.github/workflows/gallery_notify.yml @@ -13,6 +13,8 @@ on: - "scripts/notify-gallery-links.mjs" - ".github/workflows/gallery_check.yml" - ".github/workflows/gallery_notify.yml" + - "package.json" + - "yarn.lock" permissions: contents: read @@ -36,7 +38,7 @@ jobs: uses: actions/cache@v4 with: path: ${{ env.STATE_PATH }} - key: gallery-link-state-${{ github.repository }}-${{ github.ref_name }} + key: gallery-link-state-${{ github.repository }}-${{ github.ref_name }}-${{ github.run_id }} restore-keys: | gallery-link-state-${{ github.repository }}-${{ github.ref_name }}- - name: Check gallery links @@ -55,7 +57,7 @@ jobs: uses: actions/cache/save@v4 with: path: ${{ env.STATE_PATH }} - key: gallery-link-state-${{ github.repository }}-${{ github.ref_name }} + key: gallery-link-state-${{ github.repository }}-${{ github.ref_name }}-${{ github.run_id }} - name: Preserve maintenance result if: ${{ always() }} run: | diff --git a/scripts/notify-gallery-links.mjs b/scripts/notify-gallery-links.mjs index 143e8af..cb0c1fe 100644 --- a/scripts/notify-gallery-links.mjs +++ b/scripts/notify-gallery-links.mjs @@ -65,7 +65,7 @@ function filteredFailures(report) { } return failure; }) - .filter((failure) => ALLOWED_REASONS.has(failure.reason)); + .filter((failure) => ALLOWED_REASONS.has(failure.status)); const byKey = new Map(); for (const failure of candidates) { if (!failure.key) throw new Error("Malformed report"); @@ -74,6 +74,7 @@ function filteredFailures(report) { key: failure.key, name: failure.name || failure.key, reason: failure.reason, + status: failure.status, }); } return [...byKey.values()]; @@ -124,6 +125,7 @@ function makePayload({ transition, failures, previous, workflowUrl }) { content.length <= MAX_CONTENT ? content : `${content.slice(0, MAX_CONTENT - 1)}…`, + allowed_mentions: { parse: [] }, }; } @@ -198,8 +200,8 @@ export async function processNotification({ const next = { status, failureKeys: keys, - permissionCount: failures.filter((f) => f.reason === "permission").length, - invalidCount: failures.filter((f) => f.reason === "invalid").length, + permissionCount: failures.filter((f) => f.status === "permission").length, + invalidCount: failures.filter((f) => f.status === "invalid").length, }; await saveState(statePath, next); } From bee35114312cfe0f849633b78ed2362d30ceb39b Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sat, 22 Aug 2026 16:03:45 +0800 Subject: [PATCH 18/19] fix: serialize gallery maintenance runs --- .github/workflows/gallery_notify.yml | 4 ++++ scripts/check-gallery-links.mjs | 8 ++++++++ scripts/notify-gallery-links.mjs | 8 ++++++-- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gallery_notify.yml b/.github/workflows/gallery_notify.yml index d857207..6cc59a4 100644 --- a/.github/workflows/gallery_notify.yml +++ b/.github/workflows/gallery_notify.yml @@ -19,6 +19,10 @@ on: permissions: contents: read +concurrency: + group: gallery-link-maintenance-${{ github.ref_name }} + cancel-in-progress: false + env: REPORT_PATH: /tmp/gallery-link-report.json STATE_PATH: .cache/gallery-link-state.json diff --git a/scripts/check-gallery-links.mjs b/scripts/check-gallery-links.mjs index 0f88016..f0fbf3f 100644 --- a/scripts/check-gallery-links.mjs +++ b/scripts/check-gallery-links.mjs @@ -206,6 +206,14 @@ export async function loadGalleryLinks(directory = DEFAULT_DIRECTORY) { const record = JSON.parse( await readFile(join(directory, filename), "utf8"), ); + if (!record || typeof record !== "object" || Array.isArray(record)) { + records.push({ + invalidRecord: true, + filename, + error: "Invalid gallery JSON: record must be an object", + }); + continue; + } if ( record.gdrive_url === undefined || record.gdrive_url === null || diff --git a/scripts/notify-gallery-links.mjs b/scripts/notify-gallery-links.mjs index cb0c1fe..827902f 100644 --- a/scripts/notify-gallery-links.mjs +++ b/scripts/notify-gallery-links.mjs @@ -169,11 +169,15 @@ export async function processNotification({ /* corrupt/missing means no previous state */ } const status = keys.length ? "failing" : "healthy"; + const failureSignature = failures + .map(({ key, status }) => `${key}:${status}`) + .sort(); const changed = !previous || previous.status !== status || (status === "failing" && - JSON.stringify(previous.failureKeys) !== JSON.stringify(keys)); + JSON.stringify(previous.failureKeys) !== + JSON.stringify(failureSignature)); const transition = status === "healthy" ? previous?.status === "failing" @@ -199,7 +203,7 @@ export async function processNotification({ } const next = { status, - failureKeys: keys, + failureKeys: failureSignature, permissionCount: failures.filter((f) => f.status === "permission").length, invalidCount: failures.filter((f) => f.status === "invalid").length, }; From bf9f184dddc98aae88232e582ab259dfa45e8fe2 Mon Sep 17 00:00:00 2001 From: PoterPan Date: Sat, 22 Aug 2026 16:32:52 +0800 Subject: [PATCH 19/19] style: format gallery notification messages --- scripts/notify-gallery-links.mjs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/notify-gallery-links.mjs b/scripts/notify-gallery-links.mjs index 827902f..d09468e 100644 --- a/scripts/notify-gallery-links.mjs +++ b/scripts/notify-gallery-links.mjs @@ -113,13 +113,16 @@ function makePayload({ transition, failures, previous, workflowUrl }) { const omitted = failures.length - names.length; let content; if (transition === "recovery") { - content = `Gallery link checks recovered. Previous failures: ${previous.permissionCount} permission, ${previous.invalidCount} invalid.`; + content = `Gallery link checks recovered.\n\nPrevious failures: ${previous.permissionCount} permission, ${previous.invalidCount} invalid.`; } else { content = `${transition === "initial" ? "Gallery link checks failing" : "Gallery link failures changed"}: ${failures.length} affected.`; - if (names.length) - content += ` Affected galleries: ${names.join(", ")}${omitted ? ` (and ${omitted} more)` : ""}.`; + if (names.length) { + const affected = names.map((name) => `- ${name}`).join("\n"); + content += `\n\nAffected galleries:\n${affected}`; + if (omitted) content += `\n- ...and ${omitted} more`; + } } - content += ` Workflow: ${String(workflowUrl).slice(0, 500)}`; + content += `\n\nWorkflow: ${String(workflowUrl).slice(0, 500)}`; return { content: content.length <= MAX_CONTENT