This repository was archived by the owner on Jul 27, 2026. It is now read-only.
友链:𝑰𝑲𝑼𝑵 #274
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: auto-pr | |
| on: | |
| pull_request_target: | |
| types: [opened, synchronize, reopened] | |
| issue_comment: | |
| types: [created] | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| issues: write | |
| env: | |
| PAT: ${{ secrets.PAT }} | |
| concurrency: | |
| group: auto-pr-${{ github.event.pull_request.number || github.event.issue.number }} | |
| cancel-in-progress: true | |
| jobs: | |
| triage: | |
| if: ${{ github.event_name == 'pull_request_target' }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Label PR by changed paths | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.PAT }} | |
| script: | | |
| const owner = context.repo.owner | |
| const repo = context.repo.repo | |
| const pull_number = context.payload.pull_request.number | |
| const LABEL_FRIEND = '友链' | |
| const LABEL_SPONSOR = '赞助' | |
| const LABEL_AVATAR_OK = '头像可达' | |
| const LABEL_AVATAR_BAD = '头像不可达' | |
| const LABEL_SITE_OK = '网站可达' | |
| const LABEL_SITE_BAD = '网站不可达' | |
| const LABEL_ALL_OK = 'URL全部可达' | |
| const LABEL_BIDIR = '双向链接验证' | |
| const LABEL_BIDIR_OK = '双向链接验证通过' | |
| const MSG = { | |
| PR_ONE_FILE: 'PR 必须仅包含一个文件变更。', | |
| PR_PATH_ERROR: '只允许更改 src/data/friends/ 或 src/data/sponsors/ 下的文件。', | |
| PR_ACTION_ERROR: '只允许新增、编辑或删除文件。', | |
| JSON_EXT_ERROR: '检测到友链/赞助目录下存在非 .json 文件,已停止处理:', | |
| JSON_PARSE_ERROR: ': JSON 解析失败', | |
| JSON_OBJECT_ERROR: ': JSON 必须是对象', | |
| VALIDATE_FAIL: '检测到数据文件校验失败:', | |
| VIP_DETECTED: '检测到 JSON 存在 vip 字段,已终止自动流程。\n\n请从以下文件移除 vip 字段后再 push 更新(移除后才会继续自动校验/自动合并):', | |
| URL_SELF_ERROR: '检测到友链 JSON 的 url 指向本站,请填写你自己的网站 URL。', | |
| BACKLINK_SELF_ERROR: '检测到友链 JSON 的 backlink 指向本站,请填写你自己网站的友链页 URL。', | |
| SITE_CONFIG_MISSING: '无法读取本站 site 配置,无法进行双向链接验证。', | |
| BIDIR_INSTRUCTIONS: (siteBase) => [ | |
| '基础校验已通过,需要进行双向链接验证:', | |
| '', | |
| `1) 请在你的友链页面添加本站友链(必须为绝对链接):${siteBase}`, | |
| '2) 然后更新本 PR 的友链 JSON,增加字段 backlink(填写你的友链页面 URL,必须是 http/https 绝对链接)', | |
| '', | |
| '示例:', | |
| '', | |
| '```json', | |
| '{', | |
| ' "name": "...",', | |
| ' "avatar": "...",', | |
| ' "url": "...",', | |
| ' "backlink": "https://example.com/friends/"', | |
| '}', | |
| '```', | |
| '', | |
| `3) 请确保你的 backlink 页面中包含指向本站的链接 href=${siteBase} (必须完全一致的绝对链接)`, | |
| '4) push 更新后 Action 会自动重新校验并在通过后自动合并,无需额外评论', | |
| ].join('\n'), | |
| BIDIR_BACKLINK_INVALID: '双向链接验证失败:backlink 不是合法的 URL', | |
| BIDIR_BACKLINK_NOT_HTTP: '双向链接验证失败:backlink 必须以 http/https 开头', | |
| BIDIR_SITE_INVALID: '双向链接验证失败:友链 JSON 中 url 无效,无法比较主域名。', | |
| BIDIR_HOST_MISMATCH: '双向链接验证失败:backlink 的主域名必须与 url 的主域名一致。', | |
| BIDIR_FETCH_ERROR: '双向链接验证失败:访问 backlink 出错。', | |
| BIDIR_ACCESS_FAIL: '双向链接验证失败:无法访问 backlink', | |
| BIDIR_NOT_FOUND: (siteBase, backlinkUrl) => [ | |
| '双向链接验证未通过:在 backlink 页面未检测到本站友链。', | |
| '', | |
| `需要添加的绝对链接:${siteBase}`, | |
| `backlink 页面:${backlinkUrl}`, | |
| '', | |
| ].join('\n'), | |
| BIDIR_VERIFY_OK: '双向链接验证通过。', | |
| MERGE_FAIL: '自动合并失败:', | |
| DNS_VERIFY_FAIL: '域名所有权验证失败。', | |
| DNS_VERIFY_INSTRUCTIONS: (isDelete, hostname, expected) => [ | |
| `检测到你正在${isDelete ? '删除' : '修改'}现有的友链/赞助数据。为了防止误操作,请完成域名所有权验证:`, | |
| '', | |
| `1. 请在域名 ${hostname} 或 _fuwari-verification.${hostname} 下添加 DNS TXT 记录。`, | |
| `2. 记录内容:${expected}`, | |
| '3. 添加完成后,请 push 更新(或关闭并重新打开 PR)以触发重新校验。', | |
| ].join('\n'), | |
| } | |
| async function ensureLabel(name) { | |
| try { | |
| await github.rest.issues.getLabel({ owner, repo, name }) | |
| } catch (e) { | |
| if (e.status !== 404) throw e | |
| await github.rest.issues.createLabel({ | |
| owner, | |
| repo, | |
| name, | |
| color: 'ededed', | |
| }) | |
| } | |
| } | |
| async function listAllFiles() { | |
| const files = [] | |
| for await (const res of github.paginate.iterator( | |
| github.rest.pulls.listFiles, | |
| { owner, repo, pull_number, per_page: 100 } | |
| )) { | |
| for (const f of res.data) files.push(f) | |
| } | |
| return files | |
| } | |
| const files = await listAllFiles() | |
| if (files.length !== 1) { | |
| await createComment('PR 必须仅包含一个文件变更。') | |
| return | |
| } | |
| const file = files[0] | |
| if (!file.filename.startsWith('src/data/friends/') && !file.filename.startsWith('src/data/sponsors/')) { | |
| await createComment('只允许更改 src/data/friends/ 或 src/data/sponsors/ 下的文件。') | |
| return | |
| } | |
| if (file.status !== 'added' && file.status !== 'modified' && file.status !== 'removed') { | |
| await createComment('只允许新增、编辑或删除文件。') | |
| return | |
| } | |
| const paths = files.map((f) => f.filename) | |
| const touchedFriends = file.filename.startsWith('src/data/friends/') | |
| const touchedSponsors = file.filename.startsWith('src/data/sponsors/') | |
| for (const name of [ | |
| LABEL_FRIEND, | |
| LABEL_SPONSOR, | |
| LABEL_AVATAR_OK, | |
| LABEL_AVATAR_BAD, | |
| LABEL_SITE_OK, | |
| LABEL_SITE_BAD, | |
| LABEL_ALL_OK, | |
| LABEL_BIDIR, | |
| LABEL_BIDIR_OK, | |
| ]) { | |
| await ensureLabel(name) | |
| } | |
| const add = [] | |
| const remove = [] | |
| if (touchedFriends) add.push(LABEL_FRIEND) | |
| if (touchedSponsors) add.push(LABEL_SPONSOR) | |
| if (touchedFriends && !touchedSponsors) remove.push(LABEL_SPONSOR) | |
| if (touchedSponsors && !touchedFriends) remove.push(LABEL_FRIEND) | |
| if (add.length) { | |
| await github.rest.issues.addLabels({ owner, repo, issue_number: pull_number, labels: add }) | |
| } | |
| for (const name of remove) { | |
| try { | |
| await github.rest.issues.removeLabel({ owner, repo, issue_number: pull_number, name }) | |
| } catch (e) { | |
| if (e.status !== 404) throw e | |
| } | |
| } | |
| async function createComment(body) { | |
| const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` | |
| const footer = `\n\n---\n该评论由 Action 自动化发送,无需回复。\n\n🔗 [查看 Action](${runUrl})` | |
| const fullBody = body + footer | |
| core.info(`createComment len=${String(fullBody || '').length}`) | |
| try { | |
| await github.rest.issues.createComment({ owner, repo, issue_number: pull_number, body: fullBody }) | |
| core.info('createComment ok') | |
| } catch (e) { | |
| core.error(`createComment failed: status=${e?.status || ''}`) | |
| core.error(`createComment failed: message=${e?.message || e}`) | |
| try { | |
| core.error(`createComment failed: response=${JSON.stringify(e?.response?.data || null)}`) | |
| } catch {} | |
| throw e | |
| } | |
| } | |
| function isNonEmptyString(v) { | |
| return typeof v === 'string' && v.trim().length > 0 | |
| } | |
| function parseJsonSafe(text) { | |
| try { | |
| return { ok: true, value: JSON.parse(text) } | |
| } catch (e) { | |
| return { ok: false, error: e?.message || String(e) } | |
| } | |
| } | |
| async function getTextFileFromRepo(path, ref) { | |
| const res = await github.rest.repos.getContent({ owner, repo, path, ref }) | |
| if (Array.isArray(res.data) || !res.data?.content) throw new Error(`Invalid content response for ${path}`) | |
| const raw = Buffer.from(res.data.content, res.data.encoding || 'base64').toString('utf8') | |
| return raw | |
| } | |
| async function getSiteBase() { | |
| try { | |
| const raw = await getTextFileFromRepo('src/config.ts', context.payload.repository.default_branch) | |
| const m = raw.match(/customDomain\s*=\s*["']([^"']+)["']/) | |
| if (!m) return null | |
| return `https://${m[1]}` | |
| } catch (e) { | |
| core.warning(`Failed to read src/config.ts: ${e?.message || e}`) | |
| return null | |
| } | |
| } | |
| const siteBase = await getSiteBase() | |
| // Reusable verification logic from link-utils.js (simplified for github-script) | |
| async function checkUrlReachability(url) { | |
| const controller = new AbortController() | |
| const timer = setTimeout(() => controller.abort(), 10000) | |
| try { | |
| const res = await fetch(url, { | |
| method: 'GET', | |
| redirect: 'follow', | |
| headers: { 'user-agent': 'fuwari-auto-pr/1.0 (+github actions)', accept: 'text/html,*/*;q=0.8' }, | |
| signal: controller.signal, | |
| }) | |
| const body = res.ok ? await res.text() : '' | |
| return { ok: res.ok, status: res.status, body, url: res.url } | |
| } catch (e) { | |
| return { ok: false, error: e?.message || String(e) } | |
| } finally { | |
| clearTimeout(timer) | |
| } | |
| } | |
| function verifyBacklink(html, expected) { | |
| if (!html || !expected) return false | |
| const target = expected.replace(/\/$/, '') | |
| const re = /href\s*=\s*["']([^"']+)["']/gi | |
| let m | |
| while ((m = re.exec(html)) !== null) { | |
| const href = (m[1] || '').trim() | |
| if (!href.startsWith('http')) continue | |
| const normalized = href.replace(/\/$/, '') | |
| if (normalized === target) return true | |
| } | |
| return false | |
| } | |
| function normalizeUrl(rawUrl) { | |
| if (!isNonEmptyString(rawUrl)) return null | |
| const u = rawUrl.trim() | |
| if (u.startsWith('/')) { | |
| if (!siteBase) return null | |
| return new URL(u, siteBase).toString() | |
| } | |
| try { | |
| return new URL(u).toString() | |
| } catch { | |
| return null | |
| } | |
| } | |
| async function checkUrl(url, { timeoutMs }) { | |
| const controller = new AbortController() | |
| const timer = setTimeout(() => controller.abort(), timeoutMs) | |
| try { | |
| // SSRF Protection | |
| try { | |
| const u = new URL(url) | |
| if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('Invalid protocol') | |
| const h = u.hostname | |
| if (h === 'localhost' || h === '127.0.0.1' || h === '::1') throw new Error('Localhost not allowed') | |
| if (/^10\./.test(h)) throw new Error('Private IP not allowed') | |
| if (/^192\.168\./.test(h)) throw new Error('Private IP not allowed') | |
| if (/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(h)) throw new Error('Private IP not allowed') | |
| } catch (e) { | |
| return { ok: false, error: e.message } | |
| } | |
| const res = await fetch(url, { | |
| method: 'GET', | |
| redirect: 'follow', | |
| headers: { | |
| 'user-agent': 'fuwari-auto-pr/1.0 (+github actions)', | |
| accept: 'text/html,application/json,image/*,*/*;q=0.8', | |
| }, | |
| signal: controller.signal, | |
| }) | |
| return { ok: res.status >= 200 && res.status < 400, status: res.status } | |
| } catch (e) { | |
| return { ok: false, error: e?.message || String(e) } | |
| } finally { | |
| clearTimeout(timer) | |
| } | |
| } | |
| async function setLabels({ addLabels, removeLabels, skipIfSame }) { | |
| const addSet = new Set(addLabels || []) | |
| const removeSet = new Set(removeLabels || []) | |
| for (const x of addSet) removeSet.delete(x) | |
| const current = await github.rest.issues.get({ owner, repo, issue_number: pull_number }) | |
| const currentLabels = new Set((current.data.labels || []).map((l) => l.name)) | |
| if (skipIfSame) { | |
| for (const name of addSet) { | |
| if (currentLabels.has(name)) { | |
| core.info(`Label ${name} already set, skip label operations`) | |
| return | |
| } | |
| } | |
| } | |
| const finalRemove = Array.from(removeSet).filter((name) => currentLabels.has(name)) | |
| if (finalRemove.length) { | |
| for (const name of finalRemove) { | |
| try { | |
| await github.rest.issues.removeLabel({ owner, repo, issue_number: pull_number, name }) | |
| } catch (e) { | |
| if (e.status !== 404) throw e | |
| } | |
| } | |
| } | |
| const finalAdd = Array.from(addSet).filter((name) => !currentLabels.has(name)) | |
| if (finalAdd.length) { | |
| await github.rest.issues.addLabels({ owner, repo, issue_number: pull_number, labels: finalAdd }) | |
| } | |
| } | |
| const relevant = files.filter((f) => { | |
| if (f.filename.startsWith('src/data/friends/')) return true | |
| if (f.filename.startsWith('src/data/sponsors/')) return true | |
| return false | |
| }) | |
| // Verify domain ownership for removed or modified files | |
| if (relevant.length === 1 && (relevant[0].status === 'removed' || relevant[0].status === 'modified')) { | |
| const f = relevant[0] | |
| const isDelete = f.status === 'removed' | |
| try { | |
| const raw = await getTextFileFromRepo(f.filename, context.payload.pull_request.base.sha) | |
| const parsed = parseJsonSafe(raw) | |
| if (parsed.ok && parsed.value && parsed.value.url) { | |
| let hostname | |
| try { | |
| hostname = new URL(parsed.value.url).hostname | |
| } catch {} | |
| if (hostname) { | |
| const dns = require('dns').promises | |
| const expected = `fuwari-verification=${pull_number}` | |
| const domains = [hostname, `_fuwari-verification.${hostname}`] | |
| let verified = false | |
| for (const d of domains) { | |
| try { | |
| const records = await dns.resolveTxt(d) | |
| const flat = records.flat() | |
| if (flat.some(t => t.includes(expected))) { | |
| verified = true | |
| break | |
| } | |
| } catch {} | |
| } | |
| if (!verified) { | |
| await createComment(MSG.DNS_VERIFY_FAIL + '\n\n' + MSG.DNS_VERIFY_INSTRUCTIONS(isDelete, hostname, expected)) | |
| return | |
| } | |
| if (isDelete) { | |
| await github.rest.pulls.merge({ owner, repo, pull_number, merge_method: 'squash' }) | |
| return | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| core.warning(`Ownership check failed: ${e}`) | |
| } | |
| } | |
| const invalidSuffix = relevant.filter((f) => !f.filename.endsWith('.json')).map((f) => f.filename) | |
| if (invalidSuffix.length) { | |
| await createComment(MSG.JSON_EXT_ERROR + '\n\n' + invalidSuffix.map((p) => `- ${p}`).join('\n')) | |
| return | |
| } | |
| // Skip further checks if only file removed (already handled or invalid) | |
| if (relevant.length === 1 && relevant[0].status === 'removed') return | |
| const headSha = context.payload.pull_request.head.sha | |
| const parsedEntries = [] | |
| const errors = [] | |
| for (const f of relevant) { | |
| const raw = await getTextFileFromRepo(f.filename, headSha) | |
| const parsed = parseJsonSafe(raw) | |
| if (!parsed.ok) { | |
| errors.push(`${f.filename}${MSG.JSON_PARSE_ERROR}(${parsed.error})`) | |
| continue | |
| } | |
| if (!parsed.value || typeof parsed.value !== 'object' || Array.isArray(parsed.value)) { | |
| errors.push(`${f.filename}${MSG.JSON_OBJECT_ERROR}`) | |
| continue | |
| } | |
| const kind = f.filename.startsWith('src/data/friends/') ? 'friend' : 'sponsor' | |
| parsedEntries.push({ path: f.filename, kind, data: parsed.value }) | |
| } | |
| function validateEntry({ kind, data, path }) { | |
| const entryErrors = [] | |
| if (!isNonEmptyString(data.name)) entryErrors.push('name 必填') | |
| if (!isNonEmptyString(data.avatar)) entryErrors.push('avatar 必填') | |
| if (kind === 'friend') { | |
| if (!isNonEmptyString(data.url)) entryErrors.push('url 必填') | |
| } | |
| if (kind === 'sponsor') { | |
| if (!isNonEmptyString(data.date)) entryErrors.push('date 必填') | |
| if (!isNonEmptyString(data.amount)) entryErrors.push('amount 必填') | |
| } | |
| if (entryErrors.length) return `${path}: ${entryErrors.join('、')}` | |
| return null | |
| } | |
| for (const e of parsedEntries) { | |
| const err = validateEntry(e) | |
| if (err) errors.push(err) | |
| } | |
| if (errors.length) { | |
| await createComment([MSG.VALIDATE_FAIL, '', ...errors.map((x) => `- ${x}`)].join('\n')) | |
| return | |
| } | |
| const vipPaths = parsedEntries | |
| .filter((e) => Object.prototype.hasOwnProperty.call(e.data, 'vip')) | |
| .map((e) => e.path) | |
| if (vipPaths.length) { | |
| await createComment( | |
| [ | |
| MSG.VIP_DETECTED, | |
| '', | |
| ...vipPaths.map((p) => `- ${p}`), | |
| ].join('\n') | |
| ) | |
| return | |
| } | |
| const primaryKind = touchedSponsors ? 'sponsor' : 'friend' | |
| const primary = parsedEntries.find((e) => e.kind === primaryKind) || parsedEntries[0] | |
| await github.rest.pulls.update({ | |
| owner, | |
| repo, | |
| pull_number, | |
| title: `${primary.kind === 'friend' ? '友链' : '赞助'}:${primary.data.name}`, | |
| }) | |
| const avatarUrl = normalizeUrl(primary.data.avatar) | |
| const siteUrl = normalizeUrl(primary.data.url) | |
| const selfSite = siteBase ? siteBase.replace(/\/$/, '') : null | |
| const normalizedSiteUrl = siteUrl ? siteUrl.replace(/\/$/, '') : null | |
| const normalizedBacklinkRaw = isNonEmptyString(primary.data.backlink) ? primary.data.backlink.trim() : null | |
| const normalizedBacklinkUrl = normalizedBacklinkRaw | |
| ? (() => { | |
| try { | |
| return new URL(normalizedBacklinkRaw).toString().replace(/\/$/, '') | |
| } catch { | |
| return null | |
| } | |
| })() | |
| : null | |
| if (primary.kind === 'friend' && selfSite) { | |
| if (normalizedSiteUrl === selfSite) { | |
| await createComment(MSG.URL_SELF_ERROR) | |
| return | |
| } | |
| if (normalizedBacklinkUrl === selfSite) { | |
| await createComment(MSG.BACKLINK_SELF_ERROR) | |
| return | |
| } | |
| } | |
| const avatarCheck = avatarUrl ? await checkUrlReachability(avatarUrl) : { ok: false, error: 'Invalid avatar URL' } | |
| const siteCheck = | |
| primary.kind === 'friend' | |
| ? siteUrl | |
| ? await checkUrlReachability(siteUrl) | |
| : { ok: false, error: 'Invalid site URL' } | |
| : siteUrl | |
| ? await checkUrlReachability(siteUrl) | |
| : null | |
| const addReach = [] | |
| const removeReach = [LABEL_AVATAR_OK, LABEL_AVATAR_BAD, LABEL_SITE_OK, LABEL_SITE_BAD, LABEL_ALL_OK] | |
| addReach.push(avatarCheck.ok ? LABEL_AVATAR_OK : LABEL_AVATAR_BAD) | |
| if (siteCheck) addReach.push(siteCheck.ok ? LABEL_SITE_OK : LABEL_SITE_BAD) | |
| const allOk = avatarCheck.ok && (!siteCheck || siteCheck.ok) | |
| if (allOk && siteCheck) addReach.push(LABEL_ALL_OK) | |
| await setLabels({ addLabels: addReach, removeLabels: removeReach, skipIfSame: true }) | |
| // Auto merge if it is an edit (modified) and all checks pass | |
| if (relevant.length === 1 && relevant[0].status === 'modified' && allOk) { | |
| try { | |
| await github.rest.pulls.merge({ owner, repo, pull_number, merge_method: 'squash' }) | |
| return | |
| } catch {} | |
| } | |
| const existingLabelNames = new Set((context.payload.pull_request.labels || []).map((l) => l.name)) | |
| core.info(`bidir: event.action=${context.payload.action || ''}`) | |
| core.info(`bidir: siteBase=${siteBase || ''}`) | |
| core.info(`bidir: existingLabels=${Array.from(existingLabelNames).join(',')}`) | |
| if (primary.kind === 'friend') { | |
| if (!siteBase) { | |
| core.info('bidir: missing siteBase -> comment and return') | |
| await createComment(MSG.SITE_CONFIG_MISSING) | |
| return | |
| } | |
| if (!existingLabelNames.has(LABEL_BIDIR) && !existingLabelNames.has(LABEL_BIDIR_OK)) { | |
| core.info('bidir: add LABEL_BIDIR') | |
| await github.rest.issues.addLabels({ owner, repo, issue_number: pull_number, labels: [LABEL_BIDIR] }) | |
| } | |
| const expected = siteBase.replace(/\/$/, '') | |
| const backlink = primary.data.backlink | |
| core.info(`bidir: url=${String(siteUrl || '')}`) | |
| core.info(`bidir: backlink=${String(backlink || '')}`) | |
| if (!isNonEmptyString(backlink)) { | |
| core.info('bidir: missing backlink -> setLabels + comment') | |
| await setLabels({ addLabels: [LABEL_BIDIR], removeLabels: [LABEL_BIDIR_OK] }) | |
| await createComment(MSG.BIDIR_INSTRUCTIONS(siteBase)) | |
| return | |
| } | |
| let backlinkUrl | |
| try { | |
| backlinkUrl = new URL(backlink).toString() | |
| } catch { | |
| core.info('bidir: backlink invalid url -> setLabels + comment') | |
| await setLabels({ addLabels: [LABEL_BIDIR], removeLabels: [LABEL_BIDIR_OK] }) | |
| await createComment(`${MSG.BIDIR_BACKLINK_INVALID}(${backlink})`) | |
| return | |
| } | |
| if (!backlinkUrl.startsWith('http://') && !backlinkUrl.startsWith('https://')) { | |
| core.info('bidir: backlink not http(s) -> setLabels + comment') | |
| await setLabels({ addLabels: [LABEL_BIDIR], removeLabels: [LABEL_BIDIR_OK] }) | |
| await createComment(`${MSG.BIDIR_BACKLINK_NOT_HTTP}(${backlink})`) | |
| return | |
| } | |
| let siteUrlObj | |
| try { | |
| siteUrlObj = new URL(siteUrl) | |
| } catch { | |
| core.info('bidir: siteUrl invalid -> setLabels + comment') | |
| await setLabels({ addLabels: [LABEL_BIDIR], removeLabels: [LABEL_BIDIR_OK] }) | |
| await createComment(MSG.BIDIR_SITE_INVALID) | |
| return | |
| } | |
| const backlinkHost = new URL(backlinkUrl).host.toLowerCase() | |
| const siteHost = siteUrlObj.host.toLowerCase() | |
| core.info(`bidir: host url=${siteHost} backlink=${backlinkHost}`) | |
| if (backlinkHost !== siteHost) { | |
| core.info('bidir: host mismatch -> setLabels + comment') | |
| await setLabels({ addLabels: [LABEL_BIDIR], removeLabels: [LABEL_BIDIR_OK] }) | |
| await createComment( | |
| [ | |
| MSG.BIDIR_HOST_MISMATCH, | |
| '', | |
| `url:${siteUrlObj.toString()}`, | |
| `backlink:${backlinkUrl}`, | |
| ].join('\n') | |
| ) | |
| return | |
| } | |
| function extractTitle(html) { | |
| if (!isNonEmptyString(html)) return null | |
| const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i) | |
| if (!m) return null | |
| const t = String(m[1] || '').replace(/\s+/g, ' ').trim() | |
| return t.slice(0, 200) | |
| } | |
| function formatBacklinkDebug(d) { | |
| const lines = ['调试信息:'] | |
| if (isNonEmptyString(d?.finalUrl)) lines.push(`- 最终 URL:${d.finalUrl}`) | |
| if (typeof d?.status === 'number') lines.push(`- 状态码:${d.status}`) | |
| if (isNonEmptyString(d?.contentType)) lines.push(`- Content-Type:${d.contentType}`) | |
| if (isNonEmptyString(d?.title)) lines.push(`- 标题:${d.title}`) | |
| return lines.join('\n') | |
| } | |
| const fetched = await checkUrlReachability(backlinkUrl) | |
| if (!fetched.ok) { | |
| core.info(`bidir: fetch backlink not ok status=${fetched.status}`) | |
| await setLabels({ addLabels: [LABEL_BIDIR], removeLabels: [LABEL_BIDIR_OK] }) | |
| await createComment( | |
| [ | |
| `${MSG.BIDIR_ACCESS_FAIL}(${fetched.status || fetched.error})。`, | |
| '', | |
| `backlink:${backlinkUrl}`, | |
| formatBacklinkDebug({ | |
| finalUrl: fetched.url, | |
| status: fetched.status, | |
| }), | |
| ].join('\n') | |
| ) | |
| return | |
| } | |
| core.info('bidir: fetch backlink ok, read text') | |
| const html = fetched.body || '' | |
| core.info(`bidir: html length=${html.length}`) | |
| const found = verifyBacklink(html, expected) | |
| core.info(`bidir: expected=${expected} found=${found}`) | |
| if (!found) { | |
| core.info('bidir: not found -> setLabels + comment') | |
| await setLabels({ addLabels: [LABEL_BIDIR], removeLabels: [LABEL_BIDIR_OK] }) | |
| const debugInfo = { | |
| finalUrl: fetched.url, | |
| status: fetched.status, | |
| title: extractTitle(html), | |
| } | |
| await createComment(MSG.BIDIR_NOT_FOUND(siteBase, backlinkUrl) + formatBacklinkDebug(debugInfo) + '\n\n' + '请添加后 push 更新(无需评论)。') | |
| return | |
| } | |
| await setLabels({ addLabels: [LABEL_BIDIR_OK], removeLabels: [LABEL_BIDIR] }) | |
| core.info('bidir: verified ok -> merge') | |
| try { | |
| await github.rest.pulls.merge({ owner, repo, pull_number, merge_method: 'squash' }) | |
| } catch (e) { | |
| await createComment(`${MSG.MERGE_FAIL}${e?.message || e}`) | |
| return | |
| } | |
| } | |
| verify_and_merge: | |
| if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request && github.event.comment.body == '准备完毕' }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Auto-merge | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ env.PAT }} | |
| script: | | |
| const owner = context.repo.owner | |
| const repo = context.repo.repo | |
| const pull_number = context.payload.issue.number | |
| const LABEL_FRIEND = '友链' | |
| const LABEL_SPONSOR = '赞助' | |
| const LABEL_AVATAR_OK = '头像可达' | |
| const LABEL_AVATAR_BAD = '头像不可达' | |
| const LABEL_SITE_OK = '网站可达' | |
| const LABEL_SITE_BAD = '网站不可达' | |
| const LABEL_ALL_OK = 'URL全部可达' | |
| const LABEL_BIDIR = '双向链接验证' | |
| const LABEL_BIDIR_OK = '双向链接验证通过' | |
| const MSG = { | |
| PR_ONE_FILE: 'PR 必须仅包含一个文件变更。', | |
| PR_PATH_ERROR: '只允许更改 src/data/friends/ 或 src/data/sponsors/ 下的文件。', | |
| PR_ACTION_ERROR: '只允许新增、编辑或删除文件。', | |
| JSON_EXT_ERROR: '检测到友链/赞助目录下存在非 .json 文件,已停止处理:', | |
| JSON_PARSE_ERROR: ': JSON 解析失败', | |
| JSON_OBJECT_ERROR: ': JSON 必须是对象', | |
| VALIDATE_FAIL: '检测到数据文件校验失败:', | |
| VIP_DETECTED: '检测到 JSON 存在 vip 字段,已终止自动流程。\n\n请从以下文件移除 vip 字段后再 push 更新,然后再次回复“准备完毕”:', | |
| URL_SELF_ERROR: '检测到友链 JSON 的 url 指向本站,请填写你自己的网站 URL。', | |
| BACKLINK_SELF_ERROR: '检测到友链 JSON 的 backlink 指向本站,请填写你自己网站的友链页 URL。', | |
| SITE_CONFIG_MISSING: '无法读取本站 site 配置,无法进行双向链接验证。', | |
| BIDIR_INSTRUCTIONS: '该 PR 走双向链接验证流程:请在友链 JSON 中填写 backlink 字段并 push 更新,无需回复“准备完毕”。', | |
| MERGE_FAIL: '自动合并失败。', | |
| DNS_VERIFY_FAIL: '域名所有权验证失败。', | |
| DNS_VERIFY_INSTRUCTIONS: (isDelete, hostname, expected) => [ | |
| `检测到你正在${isDelete ? '删除' : '修改'}现有的友链/赞助数据。为了防止误操作,请完成域名所有权验证:`, | |
| '', | |
| `1. 请在域名 ${hostname} 或 _fuwari-verification.${hostname} 下添加 DNS TXT 记录。`, | |
| `2. 记录内容:${expected}`, | |
| '3. 添加完成后,请回复“准备完毕”以触发重新校验。', | |
| ].join('\n'), | |
| LINK_UNREACHABLE: '检测到链接不可达,请修复后再次回复“准备完毕”。', | |
| } | |
| function isNonEmptyString(v) { | |
| return typeof v === 'string' && v.trim().length > 0 | |
| } | |
| async function createComment(body) { | |
| const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}` | |
| const footer = `\n\n---\n该评论由 Action 自动化发送,无需回复。\n\n🔗 [查看 Action](${runUrl})` | |
| const fullBody = body + footer | |
| await github.rest.issues.createComment({ owner, repo, issue_number: pull_number, body: fullBody }) | |
| } | |
| function parseJsonSafe(text) { | |
| try { | |
| return { ok: true, value: JSON.parse(text) } | |
| } catch (e) { | |
| return { ok: false, error: e?.message || String(e) } | |
| } | |
| } | |
| async function getTextFileFromRepo(path, ref) { | |
| const res = await github.rest.repos.getContent({ owner, repo, path, ref }) | |
| if (Array.isArray(res.data) || !res.data?.content) throw new Error(`Invalid content response for ${path}`) | |
| const raw = Buffer.from(res.data.content, res.data.encoding || 'base64').toString('utf8') | |
| return raw | |
| } | |
| async function getSiteBase(defaultBranch) { | |
| try { | |
| const raw = await getTextFileFromRepo('src/config.ts', defaultBranch) | |
| const m = raw.match(/customDomain\s*=\s*["']([^"']+)["']/) | |
| if (!m) return null | |
| return `https://${m[1]}` | |
| } catch { | |
| return null | |
| } | |
| } | |
| function normalizeUrl(rawUrl, siteBase) { | |
| if (!isNonEmptyString(rawUrl)) return null | |
| const u = rawUrl.trim() | |
| if (u.startsWith('/')) { | |
| if (!siteBase) return null | |
| return new URL(u, siteBase).toString() | |
| } | |
| try { | |
| return new URL(u).toString() | |
| } catch { | |
| return null | |
| } | |
| } | |
| async function checkUrl(url, { timeoutMs }) { | |
| const controller = new AbortController() | |
| const timer = setTimeout(() => controller.abort(), timeoutMs) | |
| try { | |
| // SSRF Protection | |
| try { | |
| const u = new URL(url) | |
| if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('Invalid protocol') | |
| const h = u.hostname | |
| if (h === 'localhost' || h === '127.0.0.1' || h === '::1') throw new Error('Localhost not allowed') | |
| if (/^10\./.test(h)) throw new Error('Private IP not allowed') | |
| if (/^192\.168\./.test(h)) throw new Error('Private IP not allowed') | |
| if (/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(h)) throw new Error('Private IP not allowed') | |
| } catch (e) { | |
| return { ok: false, error: e.message } | |
| } | |
| const res = await fetch(url, { | |
| method: 'GET', | |
| redirect: 'follow', | |
| headers: { | |
| 'user-agent': 'fuwari-auto-pr/1.0 (+github actions)', | |
| accept: 'text/html,application/json,image/*,*/*;q=0.8', | |
| }, | |
| signal: controller.signal, | |
| }) | |
| return { ok: res.status >= 200 && res.status < 400, status: res.status } | |
| } catch (e) { | |
| return { ok: false, error: e?.message || String(e) } | |
| } finally { | |
| clearTimeout(timer) | |
| } | |
| } | |
| async function setLabels({ addLabels, removeLabels, skipIfSame }) { | |
| const addSet = new Set(addLabels || []) | |
| const removeSet = new Set(removeLabels || []) | |
| for (const x of addSet) removeSet.delete(x) | |
| const current = await github.rest.issues.get({ owner, repo, issue_number: pull_number }) | |
| const currentLabels = new Set((current.data.labels || []).map((l) => l.name)) | |
| if (skipIfSame) { | |
| for (const name of addSet) { | |
| if (currentLabels.has(name)) { | |
| core.info(`Label ${name} already set, skip label operations`) | |
| return | |
| } | |
| } | |
| } | |
| const finalRemove = Array.from(removeSet).filter((name) => currentLabels.has(name)) | |
| if (finalRemove.length) { | |
| for (const name of finalRemove) { | |
| try { | |
| await github.rest.issues.removeLabel({ owner, repo, issue_number: pull_number, name }) | |
| } catch (e) { | |
| if (e.status !== 404) throw e | |
| } | |
| } | |
| } | |
| const finalAdd = Array.from(addSet).filter((name) => !currentLabels.has(name)) | |
| if (finalAdd.length) { | |
| await github.rest.issues.addLabels({ owner, repo, issue_number: pull_number, labels: finalAdd }) | |
| } | |
| } | |
| async function listAllFiles() { | |
| const files = [] | |
| for await (const res of github.paginate.iterator( | |
| github.rest.pulls.listFiles, | |
| { owner, repo, pull_number, per_page: 100 } | |
| )) { | |
| for (const f of res.data) files.push(f) | |
| } | |
| return files | |
| } | |
| const pr = await github.rest.pulls.get({ owner, repo, pull_number }) | |
| core.info(`event_name=${context.eventName}`) | |
| core.info(`actor=${context.actor}`) | |
| core.info(`issue_comment_author=${context.payload.comment?.user?.login || ''}`) | |
| core.info(`pr.number=${pr.data.number}`) | |
| core.info(`pr.state=${pr.data.state}, pr.draft=${pr.data.draft}`) | |
| core.info(`pr.user=${pr.data.user?.login || ''}`) | |
| core.info(`pr.head.ref=${pr.data.head?.ref || ''}`) | |
| core.info(`pr.head.sha=${pr.data.head?.sha || ''}`) | |
| core.info(`pr.head.repo.fork=${pr.data.head?.repo?.fork}`) | |
| core.info(`pr.base.ref=${pr.data.base?.ref || ''}`) | |
| core.info(`pr.base.repo.private=${pr.data.base?.repo?.private}`) | |
| core.info(`pr.maintainer_can_modify=${pr.data.maintainer_can_modify}`) | |
| core.info(`pr.mergeable=${pr.data.mergeable}`) | |
| core.info(`pr.mergeable_state=${pr.data.mergeable_state}`) | |
| core.info(`pr.rebaseable=${pr.data.rebaseable}`) | |
| core.info(`pr.auto_merge=${pr.data.auto_merge ? 'enabled' : 'disabled'}`) | |
| core.info(`pr.labels=${(pr.data.labels || []).map((l) => l.name).join(',')}`) | |
| try { | |
| const auth = await github.rest.apps.getAuthenticated() | |
| core.info(`authenticated_app=${auth.data?.name || ''}`) | |
| } catch (e) { | |
| core.info(`getAuthenticated failed: ${e?.status || ''} ${e?.message || e}`) | |
| } | |
| const prLabels = new Set((pr.data.labels || []).map((l) => l.name)) | |
| if (!prLabels.has(LABEL_FRIEND) && !prLabels.has(LABEL_SPONSOR)) { | |
| core.info('PR is not labeled as 友链/赞助. Skip.') | |
| return | |
| } | |
| if (prLabels.has(LABEL_BIDIR) || prLabels.has(LABEL_BIDIR_OK)) { | |
| await createComment('该 PR 走双向链接验证流程:请在友链 JSON 中填写 backlink 字段并 push 更新,无需回复“准备完毕”。') | |
| return | |
| } | |
| const files = await listAllFiles() | |
| if (files.length !== 1) { | |
| await createComment(MSG.PR_ONE_FILE) | |
| return | |
| } | |
| const file = files[0] | |
| if (!file.filename.startsWith('src/data/friends/') && !file.filename.startsWith('src/data/sponsors/')) { | |
| await createComment(MSG.PR_PATH_ERROR) | |
| return | |
| } | |
| if (file.status !== 'added' && file.status !== 'modified' && file.status !== 'removed') { | |
| await createComment(MSG.PR_ACTION_ERROR) | |
| return | |
| } | |
| const relevant = files | |
| // Verify domain ownership for removed or modified files | |
| if (relevant.length === 1 && (relevant[0].status === 'removed' || relevant[0].status === 'modified')) { | |
| const f = relevant[0] | |
| const isDelete = f.status === 'removed' | |
| try { | |
| const raw = await getTextFileFromRepo(f.filename, pr.data.base.sha) | |
| const parsed = parseJsonSafe(raw) | |
| if (parsed.ok && parsed.value && parsed.value.url) { | |
| let hostname | |
| try { | |
| hostname = new URL(parsed.value.url).hostname | |
| } catch {} | |
| if (hostname) { | |
| const dns = require('dns').promises | |
| const expected = `fuwari-verification=${pull_number}` | |
| const domains = [hostname, `_fuwari-verification.${hostname}`] | |
| let verified = false | |
| for (const d of domains) { | |
| try { | |
| const records = await dns.resolveTxt(d) | |
| const flat = records.flat() | |
| if (flat.some(t => t.includes(expected))) { | |
| verified = true | |
| break | |
| } | |
| } catch {} | |
| } | |
| if (!verified) { | |
| await createComment(MSG.DNS_VERIFY_FAIL + '\n\n' + MSG.DNS_VERIFY_INSTRUCTIONS(isDelete, hostname, expected)) | |
| return | |
| } | |
| if (isDelete) { | |
| await github.rest.pulls.merge({ owner, repo, pull_number, merge_method: 'squash' }) | |
| return | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| core.warning(`Ownership check failed: ${e}`) | |
| } | |
| } | |
| const invalidSuffix = relevant.filter((f) => !f.filename.endsWith('.json')).map((f) => f.filename) | |
| if (invalidSuffix.length) { | |
| await createComment(MSG.JSON_EXT_ERROR + '\n\n' + invalidSuffix.map((p) => `- ${p}`).join('\n')) | |
| return | |
| } | |
| // Skip further checks if only file removed (already handled or invalid) | |
| if (relevant.length === 1 && relevant[0].status === 'removed') return | |
| const parsedEntries = [] | |
| const errors = [] | |
| for (const f of relevant) { | |
| const raw = await getTextFileFromRepo(f.filename, pr.data.head.sha) | |
| const parsed = parseJsonSafe(raw) | |
| if (!parsed.ok) { | |
| errors.push(`${f.filename}${MSG.JSON_PARSE_ERROR}(${parsed.error})`) | |
| continue | |
| } | |
| if (!parsed.value || typeof parsed.value !== 'object' || Array.isArray(parsed.value)) { | |
| errors.push(`${f.filename}${MSG.JSON_OBJECT_ERROR}`) | |
| continue | |
| } | |
| const kind = f.filename.startsWith('src/data/friends/') ? 'friend' : 'sponsor' | |
| parsedEntries.push({ path: f.filename, kind, data: parsed.value }) | |
| } | |
| function validateEntry({ kind, data, path }) { | |
| const entryErrors = [] | |
| if (!isNonEmptyString(data.name)) entryErrors.push('name 必填') | |
| if (!isNonEmptyString(data.avatar)) entryErrors.push('avatar 必填') | |
| if (kind === 'friend') { | |
| if (!isNonEmptyString(data.url)) entryErrors.push('url 必填') | |
| } | |
| if (kind === 'sponsor') { | |
| if (!isNonEmptyString(data.date)) entryErrors.push('date 必填') | |
| if (!isNonEmptyString(data.amount)) entryErrors.push('amount 必填') | |
| } | |
| if (entryErrors.length) return `${path}: ${entryErrors.join('、')}` | |
| return null | |
| } | |
| for (const e of parsedEntries) { | |
| const err = validateEntry(e) | |
| if (err) errors.push(err) | |
| } | |
| if (errors.length) { | |
| await createComment([MSG.VALIDATE_FAIL, '', ...errors.map((x) => `- ${x}`)].join('\n')) | |
| return | |
| } | |
| const vipPaths = parsedEntries | |
| .filter((e) => Object.prototype.hasOwnProperty.call(e.data, 'vip')) | |
| .map((e) => e.path) | |
| if (vipPaths.length) { | |
| await createComment( | |
| [ | |
| MSG.VIP_DETECTED, | |
| '', | |
| ...vipPaths.map((p) => `- ${p}`), | |
| ].join('\n') | |
| ) | |
| return | |
| } | |
| const primaryKind = prLabels.has(LABEL_SPONSOR) ? 'sponsor' : 'friend' | |
| const primary = parsedEntries.find((e) => e.kind === primaryKind) || parsedEntries[0] | |
| await github.rest.pulls.update({ | |
| owner, | |
| repo, | |
| pull_number, | |
| title: `${primary.kind === 'friend' ? '友链' : '赞助'}:${primary.data.name}`, | |
| }) | |
| const siteBase = await getSiteBase(pr.data.base.repo.default_branch) | |
| async function checkUrlReachability(url) { | |
| const controller = new AbortController() | |
| const timer = setTimeout(() => controller.abort(), 10000) | |
| try { | |
| const res = await fetch(url, { | |
| method: 'GET', | |
| redirect: 'follow', | |
| headers: { 'user-agent': 'fuwari-auto-pr/1.0 (+github actions)', accept: 'text/html,*/*;q=0.8' }, | |
| signal: controller.signal, | |
| }) | |
| const body = res.ok ? await res.text() : '' | |
| return { ok: res.ok, status: res.status, body, url: res.url } | |
| } catch (e) { | |
| return { ok: false, error: e?.message || String(e) } | |
| } finally { | |
| clearTimeout(timer) | |
| } | |
| } | |
| const avatarUrl = normalizeUrl(primary.data.avatar, siteBase) | |
| const siteUrl = normalizeUrl(primary.data.url, siteBase) | |
| const selfSite = siteBase ? siteBase.replace(/\/$/, '') : null | |
| const normalizedSiteUrl = siteUrl ? siteUrl.replace(/\/$/, '') : null | |
| if (primary.kind === 'friend' && selfSite && normalizedSiteUrl === selfSite) { | |
| await createComment(MSG.URL_SELF_ERROR) | |
| return | |
| } | |
| const avatarCheck = avatarUrl ? await checkUrlReachability(avatarUrl) : { ok: false, error: 'Invalid avatar URL' } | |
| const siteCheck = | |
| primary.kind === 'friend' | |
| ? siteUrl | |
| ? await checkUrlReachability(siteUrl) | |
| : { ok: false, error: 'Invalid site URL' } | |
| : siteUrl | |
| ? await checkUrlReachability(siteUrl) | |
| : null | |
| const addReach = [] | |
| const removeReach = [LABEL_AVATAR_OK, LABEL_AVATAR_BAD, LABEL_SITE_OK, LABEL_SITE_BAD, LABEL_ALL_OK] | |
| addReach.push(avatarCheck.ok ? LABEL_AVATAR_OK : LABEL_AVATAR_BAD) | |
| if (siteCheck) addReach.push(siteCheck.ok ? LABEL_SITE_OK : LABEL_SITE_BAD) | |
| const allOk = avatarCheck.ok && (!siteCheck || siteCheck.ok) | |
| if (allOk && siteCheck) addReach.push(LABEL_ALL_OK) | |
| await setLabels({ addLabels: addReach, removeLabels: removeReach, skipIfSame: true }) | |
| // Auto merge if it is an edit (modified) and all checks pass | |
| if (relevant.length === 1 && relevant[0].status === 'modified' && allOk) { | |
| try { | |
| await github.rest.pulls.merge({ owner, repo, pull_number, merge_method: 'squash' }) | |
| return | |
| } catch {} | |
| } | |
| if (!avatarCheck.ok || (siteCheck && !siteCheck.ok)) { | |
| await createComment('检测到链接不可达,请修复后再次回复“准备完毕”。') | |
| return | |
| } | |
| try { | |
| await github.rest.pulls.merge({ owner, repo, pull_number, merge_method: 'squash' }) | |
| } catch (e) { | |
| core.error(`merge failed: status=${e?.status || ''}`) | |
| core.error(`merge failed: message=${e?.message || e}`) | |
| try { | |
| core.error(`merge failed: response=${JSON.stringify(e?.response?.data || null)}`) | |
| } catch {} | |
| await createComment( | |
| [ | |
| '自动合并失败。', | |
| '', | |
| `错误信息:${e?.message || e}`, | |
| `HTTP 状态:${e?.status || 'unknown'}`, | |
| `响应:${(() => { try { return JSON.stringify(e?.response?.data || null) } catch { return 'unavailable' } })()}`, | |
| ].join('\n') | |
| ) | |
| return | |
| } | |