diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index af9b4c43..e893d603 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,17 +5,25 @@ on: branches: - main # 根据您的实际情况修改分支名称 schedule: - - cron: '0 1 */2 * *' # 北京时间每2天(48小时)早上9点执行 + - cron: '0 1 * * *' # UTC 01:00 = 北京时间 09:00 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: deploy: runs-on: ubuntu-latest permissions: contents: write + env: + TZ: Asia/Shanghai steps: - name: Checkout code uses: actions/checkout@v2 + with: + fetch-depth: 0 - name: Setup Hugo uses: peaceiris/actions-hugo@v2 @@ -42,34 +50,42 @@ jobs: run: npm ci - name: Optimize Images + continue-on-error: true run: node scripts/optimize_images.js - name: Generate AI Comments + continue-on-error: true env: SILICONFLOW_API_KEY: ${{ secrets.SILICONFLOW_API_KEY }} AI_PROVIDER: siliconflow run: node scripts/ai_comment.js - name: Commit Optimized Images and AI Comments + continue-on-error: true run: | git config --global user.name "GitHub Actions" git config --global user.email "actions@github.com" git add content/post if ! git diff --staged --quiet; then git commit -m "chore: auto-optimize images and generate ai comments [skip ci]" - git push + git fetch origin main + git rebase origin/main + git push origin HEAD:main else echo "No changes to commit" fi - name: Generate Friend Circle Data + continue-on-error: true run: node scripts/generate_circle_data.js - name: Check Link Status + continue-on-error: true run: node scripts/check_links.js - name: Read Failed Links Summary + continue-on-error: true if: always() run: | if [ -f failed_links_summary.txt ]; then @@ -89,6 +105,7 @@ jobs: ./scripts/deploy_edgeone.sh hugo-blog ./public overseas - name: Debug Environment Variables + continue-on-error: true run: | echo "Bucket: ${{ secrets.UPYUN_BUCKET }}" echo "Operator: ${{ secrets.UPYUN_OPERATOR }}" @@ -117,20 +134,22 @@ jobs: run: pip install requests - name: Refresh DogeCloud CDN + continue-on-error: true env: DOGECLOUD_ACCESS_KEY: ${{ secrets.DOGECLOUD_ACCESS_KEY }} DOGECLOUD_SECRET_KEY: ${{ secrets.DOGECLOUD_SECRET_KEY }} CDN_URL_LIST: ${{ secrets.CDN_URL_LIST }} run: python scripts/RefreshCDN.py - - name: Get current date - id: date - run: echo "::set-output name=date::$(date +'%Y-%m-%d %H:%M:%S')" - - name: Generate Notification Report + continue-on-error: true run: node scripts/generate_notification_report.js - - name: Send deployment success notification + - name: Get current date (CN) + id: date + run: echo "date=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT + + - name: Send deployment notification if: success() uses: dawidd6/action-send-mail@v3 with: diff --git a/scripts/generate_notification_report.js b/scripts/generate_notification_report.js index aeb0e905..9e083efc 100644 --- a/scripts/generate_notification_report.js +++ b/scripts/generate_notification_report.js @@ -1,30 +1,91 @@ const fs = require('fs'); const path = require('path'); const matter = require('gray-matter'); +const axios = require('axios'); const FRIEND_CIRCLE_PATH = path.join(__dirname, '../themes/Ying/static/json/friend_circle_data.json'); const LINK_SUMMARY_PATH = path.join(__dirname, '../failed_links_summary.txt'); const POSTS_DIR = path.join(__dirname, '../content/post'); +const HUGO_CONFIG_PATH = path.join(__dirname, '../hugo.toml'); -const BASE_URL = "https://usj.cc"; - -// Helper to format date as YYYYMMDD function formatDateYYYYMMDDHHmm(date) { - const y = date.getFullYear(); // 获取年份(4位) - const m = String(date.getMonth() + 1).padStart(2, '0'); // 月份(补零到2位,注意月份从0开始) - const d = String(date.getDate()).padStart(2, '0'); // 日期(补零到2位) - const h = String(date.getHours()).padStart(2, '0'); // 小时(24小时制,补零到2位) - const mm = String(date.getMinutes()).padStart(2, '0'); // 分钟(补零到2位) + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + const h = String(date.getHours()).padStart(2, '0'); + const mm = String(date.getMinutes()).padStart(2, '0'); return `${y}${m}${d}${h}${mm}`; } -// Helper to get latest post from content/post -function getLatestPost() { - try { - if (!fs.existsSync(POSTS_DIR)) return null; +function isWithinDays(date, days) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) return false; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + return date >= cutoff; +} - let latestFile = null; - let latestDate = new Date(0); +function stripHtml(input) { + if (input == null) return ''; + return String(input) + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function truncate(input, maxLen) { + const s = input == null ? '' : String(input); + if (s.length <= maxLen) return s; + return `${s.slice(0, Math.max(0, maxLen - 1))}…`; +} + +function parseDateFlexible(input) { + if (input instanceof Date) return Number.isNaN(input.getTime()) ? null : input; + if (input == null) return null; + + const raw = String(input).trim(); + if (!raw) return null; + + const direct = new Date(raw); + if (!Number.isNaN(direct.getTime())) return direct; + + const digits = raw.replace(/\D/g, ''); + if (digits.length === 14 || digits.length === 12) { + const y = Number(digits.slice(0, 4)); + const mo = Number(digits.slice(4, 6)); + const d = Number(digits.slice(6, 8)); + const h = Number(digits.slice(8, 10)); + const mi = Number(digits.slice(10, 12)); + const s = digits.length === 14 ? Number(digits.slice(12, 14)) : 0; + if ([y, mo, d, h, mi, s].some(n => !Number.isFinite(n))) return null; + const dt = new Date(y, mo - 1, d, h, mi, s); + return Number.isNaN(dt.getTime()) ? null : dt; + } + + if (digits.length === 8) { + const y = Number(digits.slice(0, 4)); + const mo = Number(digits.slice(4, 6)); + const d = Number(digits.slice(6, 8)); + if ([y, mo, d].some(n => !Number.isFinite(n))) return null; + const dt = new Date(y, mo - 1, d); + return Number.isNaN(dt.getTime()) ? null : dt; + } + + return null; +} + +function formatDateMDY(date) { + try { + return date.toLocaleDateString('en-US'); + } catch (e) { + return ''; + } +} + +function collectPublicPosts() { + try { + if (!fs.existsSync(POSTS_DIR)) return []; + + const posts = []; function scanDir(dir) { const files = fs.readdirSync(dir); @@ -34,60 +95,245 @@ function getLatestPost() { if (stat.isDirectory()) { scanDir(fullPath); } else if (file.endsWith('.md')) { - // Try to read frontmatter date try { const content = fs.readFileSync(fullPath, 'utf8'); const parsed = matter(content); - if (parsed.data.date) { - const date = new Date(parsed.data.date); - // Only consider posts that are NOT hidden - if (parsed.data.status !== 'hidden' && parsed.data.draft !== true) { - if (date > latestDate) { - latestDate = date; - latestFile = { - title: parsed.data.title || file.replace('.md', ''), - date: date, - // Construct URL based on hugo.toml: /:year:month:day.html - url: `${BASE_URL}/${formatDateYYYYMMDDHHmm(date)}.html` - }; - } - } - } + const isPublic = parsed.data.status !== 'hidden' && parsed.data.draft !== true; + if (!isPublic) continue; + + const date = parsed.data.date ? parseDateFlexible(parsed.data.date) : null; + const title = parsed.data.title ? String(parsed.data.title) : file.replace('.md', ''); + const slugRaw = parsed.data.slug != null ? String(parsed.data.slug).trim() : ''; + const slug = slugRaw || (date && !Number.isNaN(date.getTime()) ? formatDateYYYYMMDDHHmm(date) : ''); + posts.push({ + title, + date, + slug + }); } catch (e) { - // Ignore parsing errors } } } } scanDir(POSTS_DIR); - return latestFile; + return posts; + } catch (e) { + console.error('Error collecting posts:', e); + return []; + } +} + +function getFileMetaString(filePath) { + try { + const stat = fs.statSync(filePath); + const mtime = stat.mtime ? stat.mtime.toLocaleString() : ''; + const sizeKb = typeof stat.size === 'number' ? Math.max(1, Math.round(stat.size / 1024)) : 0; + return mtime ? `${sizeKb}KB, 更新于 ${mtime}` : `${sizeKb}KB`; + } catch (e) { + return ''; + } +} + +function readHugoConfig() { + try { + if (!fs.existsSync(HUGO_CONFIG_PATH)) return null; + const raw = fs.readFileSync(HUGO_CONFIG_PATH, 'utf8'); + const lines = raw.split(/\r?\n/); + + let baseURL = ''; + let inArtalk = false; + let artalkServer = ''; + let artalkSite = ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + if (!baseURL) { + const mBase = trimmed.match(/^baseURL\s*=\s*"([^"]+)"\s*$/); + if (mBase) baseURL = mBase[1]; + } + + if (/^\[params\.artalk\]\s*$/.test(trimmed)) { + inArtalk = true; + continue; + } + if (inArtalk && /^\[.*\]\s*$/.test(trimmed)) break; + if (!inArtalk) continue; + + const mServer = trimmed.match(/^server\s*=\s*"([^"]+)"\s*$/); + if (mServer) { + artalkServer = mServer[1]; + continue; + } + const mSite = trimmed.match(/^site\s*=\s*"([^"]+)"\s*$/); + if (mSite) { + artalkSite = mSite[1]; + } + } + + const conf = {}; + if (baseURL) conf.baseURL = baseURL; + if (artalkServer && artalkSite) conf.artalk = { server: artalkServer, site: artalkSite }; + return Object.keys(conf).length > 0 ? conf : null; } catch (e) { - console.error('Error finding latest post:', e); return null; } } +function readArtalkConfig() { + try { + if (!fs.existsSync(HUGO_CONFIG_PATH)) return null; + const raw = fs.readFileSync(HUGO_CONFIG_PATH, 'utf8'); + const lines = raw.split(/\r?\n/); + let inSection = false; + let server = ''; + let site = ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + if (/^\[params\.artalk\]\s*$/.test(trimmed)) { + inSection = true; + continue; + } + if (inSection && /^\[.*\]\s*$/.test(trimmed)) break; + if (!inSection) continue; + + const mServer = trimmed.match(/^server\s*=\s*"([^"]+)"\s*$/); + if (mServer) { + server = mServer[1]; + continue; + } + const mSite = trimmed.match(/^site\s*=\s*"([^"]+)"\s*$/); + if (mSite) { + site = mSite[1]; + } + } + + if (!server || !site) return null; + return { server, site }; + } catch (e) { + return null; + } +} + +async function fetchArtalkCommentsForPage({ server, site, pageKey, limit }) { + const base = String(server || '').replace(/\/+$/, ''); + const siteName = String(site || '').trim(); + const key = String(pageKey || '').trim(); + if (!base || !siteName || !key) return []; + + const url = `${base}/api/v2/comments`; + try { + const resp = await axios.get(url, { + params: { site_name: siteName, page_key: key, limit: limit || 50, offset: 0 }, + timeout: 10000, + validateStatus: (s) => s >= 200 && s < 400 + }); + + const payload = resp && resp.data ? resp.data : null; + const list = payload && Array.isArray(payload.comments) ? payload.comments : []; + return list; + } catch (e) { + return []; + } +} + async function main() { let report = ""; - // 1. 最新文章 - const latestPost = getLatestPost(); - if (latestPost) { - report += `### 📝 最新文章\n`; - const dateStr = latestPost.date.toLocaleDateString(); - report += `- [${latestPost.title}](${latestPost.url}) (${dateStr})\n`; - report += `\n`; - } else { - report += `### 📝 最新文章\n(未找到公开文章)\n\n`; + const nowStr = new Date().toLocaleString(); + const tz = process.env.TZ || ''; + const repo = process.env.GITHUB_REPOSITORY || ''; + const refName = process.env.GITHUB_REF_NAME || ''; + const sha = process.env.GITHUB_SHA ? process.env.GITHUB_SHA.slice(0, 7) : ''; + const runId = process.env.GITHUB_RUN_ID || ''; + const serverUrl = process.env.GITHUB_SERVER_URL || 'https://github.com'; + const runUrl = repo && runId ? `${serverUrl}/${repo}/actions/runs/${runId}` : ''; + + report += `### 🚀 部署信息\n`; + report += `- 时间: ${nowStr}\n`; + if (tz) report += `- 时区: ${tz}\n`; + if (repo) report += `- 仓库: ${repo}\n`; + if (refName) report += `- 分支: ${refName}\n`; + if (sha) report += `- 提交: ${sha}\n`; + if (runUrl) report += `- Actions: ${runUrl}\n`; + report += `\n`; + + const posts = collectPublicPosts(); + const totalPublic = posts.length; + const dated = posts.filter(p => p.date instanceof Date && !Number.isNaN(p.date.getTime())); + const undatedCount = totalPublic - dated.length; + const recent7 = dated.filter(p => isWithinDays(p.date, 7)); + const recent30 = dated.filter(p => isWithinDays(p.date, 30)); + + report += `### 📦 内容概览\n`; + report += `- 公开文章: ${totalPublic}\n`; + report += `- 近7天更新: ${recent7.length}\n`; + report += `- 近30天更新: ${recent30.length}\n`; + if (undatedCount > 0) report += `- 缺少日期: ${undatedCount}\n`; + report += `\n`; + + try { + const hugoConf = readHugoConfig(); + const artalk = hugoConf && hugoConf.artalk ? hugoConf.artalk : readArtalkConfig(); + const baseURL = hugoConf && hugoConf.baseURL ? String(hugoConf.baseURL).replace(/\/+$/, '') : 'https://usj.cc'; + + if (artalk) { + const postsByDate = posts + .filter(p => p && typeof p.slug === 'string' && p.slug.trim()) + .filter(p => p.date instanceof Date && !Number.isNaN(p.date.getTime())) + .slice() + .sort((a, b) => b.date.getTime() - a.date.getTime()) + .slice(0, 15); + + const pageKeys = [...new Set(postsByDate.map(p => `/${String(p.slug).trim()}.html`))]; + const allComments = []; + + for (const pageKey of pageKeys) { + const pageComments = await fetchArtalkCommentsForPage({ server: artalk.server, site: artalk.site, pageKey, limit: 100 }); + pageComments.forEach(c => { + const dateRaw = c && (c.date || c.created_at || c.createdAt || c.time); + const date = dateRaw ? new Date(dateRaw) : null; + allComments.push({ + nick: c && (c.nick || c.nickname || c.name || c.user_name || c.userName) ? (c.nick || c.nickname || c.name || c.user_name || c.userName) : '匿名', + content: c && (c.content_marked || c.content || c.text || c.body), + date, + pageKey, + link: `${baseURL}${pageKey}` + }); + }); + } + + const latest = allComments + .filter(c => c.date instanceof Date && !Number.isNaN(c.date.getTime())) + .sort((a, b) => b.date.getTime() - a.date.getTime()) + .slice(0, 10); + + report += `### Artalk 评论 (近10条)\n`; + report += `- 站点: ${artalk.site}\n`; + report += `- 服务: ${artalk.server}\n`; + if (latest.length === 0) report += `(无评论或获取失败)\n`; + latest.forEach((c) => { + const content = truncate(stripHtml(c.content), 60); + const dateStr = formatDateMDY(c.date); + report += `- [${c.nick}] ${content} (${dateStr}) -> ${c.link}\n`; + }); + report += `\n`; + } + } catch (e) { } - // 2. 友链检测 try { if (fs.existsSync(LINK_SUMMARY_PATH)) { const linkSummary = fs.readFileSync(LINK_SUMMARY_PATH, 'utf8').trim(); report += `### 🔗 友链检测\n`; - report += `${linkSummary}\n\n`; + const linkMeta = getFileMetaString(LINK_SUMMARY_PATH); + if (linkMeta) report += `- 结果: ${linkMeta}\n`; + report += `${linkSummary || '(无异常)'}\n\n`; } else { report += `### 🔗 友链检测\n(无检测结果)\n\n`; } @@ -95,35 +341,63 @@ async function main() { console.error("Error reading link summary:", e); } - // 3. 近三天朋友圈 try { if (fs.existsSync(FRIEND_CIRCLE_PATH)) { const data = JSON.parse(fs.readFileSync(FRIEND_CIRCLE_PATH, 'utf8')); - const threeDaysAgo = new Date(); - threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - const recentPosts = data.posts.filter(post => new Date(post.date) > threeDaysAgo); + const allPostsSorted = (data.posts || []) + .map(p => { + const dt = parseDateFlexible(p && p.date); + return Object.assign({}, p, { __date: dt }); + }) + .filter(p => p.__date instanceof Date && !Number.isNaN(p.__date.getTime())) + .sort((a, b) => b.__date.getTime() - a.__date.getTime()); - report += `### 👥 朋友圈近况 (近3天)\n`; - if (recentPosts.length > 0) { - // Limit to top 10 to avoid too long email - const limitedPosts = recentPosts.slice(0, 10); - limitedPosts.forEach(post => { - const dateStr = new Date(post.date).toLocaleDateString(); - report += `- [${post.author}] [${post.title}](${post.link}) (${dateStr})\n`; - }); - if (recentPosts.length > 10) { - report += `... 以及其他 ${recentPosts.length - 10} 篇\n`; - } - } else { + const recentPosts = allPostsSorted.filter(post => post.__date > sevenDaysAgo); + + const authorSet = new Set(recentPosts.map(p => p.author).filter(Boolean)); + + report += `### 👥 朋友圈近况 (近7天)\n`; + const circleMeta = getFileMetaString(FRIEND_CIRCLE_PATH); + if (circleMeta) report += `- 数据: ${circleMeta}\n`; + report += `- 更新: ${recentPosts.length} 条, 活跃作者: ${authorSet.size}\n`; + const toShow = recentPosts.length > 0 ? recentPosts : allPostsSorted; + if (toShow.length === 0) { report += `无更新\n`; + report += `\n`; + } else { + if (recentPosts.length === 0) { + report += `- 近7天无更新,展示最近10条:\n`; + } + const limitedPosts = toShow.slice(0, 10); + limitedPosts.forEach(post => { + const dateStr = formatDateMDY(post.__date); + const author = post.author || '未知作者'; + const link = post.link || post.url || ''; + const blogUrl = post.blogUrl ? ` ${post.blogUrl}` : ''; + if (link) { + report += `- [${author}] \`${link}\` (${dateStr})${blogUrl}\n`; + } else { + report += `- [${author}] (${dateStr})${blogUrl}\n`; + } + }); + if (toShow.length > 10) { + report += `... 以及其他 ${toShow.length - 10} 篇\n`; + } + report += `\n`; } - report += `\n`; } } catch (e) { console.error("Error reading friend circle data:", e); } + report += `### 🧩 环境\n`; + report += `- Node: ${process.version}\n`; + if (process.env.RUNNER_OS) report += `- Runner: ${process.env.RUNNER_OS}\n`; + report += `\n`; + // Output to stdout for debugging console.log(report);