Repository navigation
Expand file tree
/
Copy pathadd-link.mjs
More file actions
226 lines (200 loc) · 7.58 KB
/
Copy pathadd-link.mjs
File metadata and controls
226 lines (200 loc) · 7.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
// 友链申请审核脚本
// 从 Issue body 解析字段 → 校验 → 通过则追加到 link.yaml
// 供 .github/workflows/add-link.yml 调用
//
// 环境变量:
// GITHUB_REPOSITORY repo 名(owner/repo)
// ISSUE_NUMBER Issue 号
// GITHUB_TOKEN 有 issues 写权限的 token
// INPUT_NAME_URL_DESC...
// 实际字段通过 stdin 接收 JSON:
// {"name":"","url":"","desc":"","avatar":"","linkpage":""}
import { readFileSync, writeFileSync, existsSync } from "node:fs";
const repo = process.env.GITHUB_REPOSITORY;
const issueNumber = process.env.ISSUE_NUMBER;
const token = process.env.GITHUB_TOKEN;
const LINK_YAML = "source/_data/link.yaml";
const SITE = "xfchannel.top";
// 反链检查:指向任一入口域名都算通过(新主域名 / GitHub Pages 源站 / 旧镜像)
const SITE_ALIASES = [SITE, "xfcnl.github.io", "husd.cc.cd", "blog.sfvg.de5.net"];
const api = "https://api.github.com";
const TIMEOUT_MS = 15000;
async function gh(method, path, body) {
const res = await fetch(api + path, {
method,
headers: {
authorization: `Bearer ${token}`,
"accept": "application/vnd.github+json",
"user-agent": "add-link",
"content-type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`GitHub API ${method} ${path} → ${res.status}`);
if (res.status === 204) return null;
return res.json();
}
async function parseInput() {
const issue = await gh("GET", `/repos/${repo}/issues/${issueNumber}`);
// GitHub API 返回的 body 用 CRLF 行尾、可能带 BOM,先归一化成 LF 再解析
const body = (issue.body || "").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
const pick = (label) => {
const m = body.match(new RegExp(`### ${label}\\s*\\n+([\\s\\S]*?)(?=\\n### |$)`));
return m ? m[1].trim() : "";
};
return {
name: pick("网站名称"),
url: pick("可访问链接"),
desc: pick("网站描述"),
avatar: pick("网站头像链接"),
linkpage: pick("友链页面链接"),
};
}
function validUrl(value) {
try {
const u = new URL(value);
return (u.protocol === "http:" || u.protocol === "https:") && u.hostname.includes(".");
} catch {
return false;
}
}
function hostnameOf(value) {
try {
return new URL(value).hostname.toLowerCase().replace(/^www\./, "");
} catch {
return "";
}
}
function comment(markdown) {
return gh("POST", `/repos/${repo}/issues/${issueNumber}/comments`, { body: markdown });
}
async function main() {
const input = await parseInput();
const { name, url, desc, avatar, linkpage } = input;
console.log(`[add-link] 收到申请: ${name} (${url})`);
const problems = [];
// 1. 字段齐全 + 校验缺失
if (!name) problems.push("**网站名称**不能为空");
if (!desc) problems.push("**网站描述**不能为空");
if (!validUrl(url)) problems.push("**可访问链接**格式不对,需要完整的 http(s) 地址");
if (!validUrl(avatar)) problems.push("**网站头像链接**格式不对,需要完整的 http(s) 地址");
if (!validUrl(linkpage)) problems.push("**友链页面链接**格式不对,需要完整的 http(s) 地址");
if (problems.length) {
await comment("❌ 友链申请失败:\n\n" + problems.map((p) => `- ${p}`).join("\n"));
process.exit(0);
}
// 2. 域名一致性:友链页面 hostname 必须与可访问链接完全一致
if (hostnameOf(linkpage) !== hostnameOf(url)) {
await comment(
"❌ 友链申请失败:**友链页面链接**的域名和**可访问链接**不一致,\n" +
`可访问:\`${hostnameOf(url)}\`,友链页:\`${hostnameOf(linkpage)}\`。\n` +
"请确保两者域名完全相同(例如 https://blog.example.com 的友链页必须是 blog.example.com/xxx)。",
);
process.exit(0);
}
// 3. 可访问性
const fail = (msg) => comment("❌ 友链申请失败:\n\n" + msg);
const ok = (msg) => comment("✅ " + msg);
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
const r1 = await fetch(url, {
redirect: "follow",
signal: ctrl.signal,
headers: { "user-agent": "Mozilla/5.0 (compatible; link-checker/1.0)" },
});
clearTimeout(t);
if (!(r1.status >= 200 && r1.status < 400)) {
await fail(`**可访问链接**返回了 HTTP ${r1.status},无法访问。`);
process.exit(0);
}
} catch {
await fail(`**可访问链接** ${url} 无法访问(连接超时或 DNS 解析失败)。`);
process.exit(0);
}
// 4. 头像可访问
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
const r2 = await fetch(avatar, {
redirect: "follow",
signal: ctrl.signal,
headers: { "user-agent": "Mozilla/5.0 (compatible; link-checker/1.0)" },
});
clearTimeout(t);
if (!(r2.status >= 200 && r2.status < 400)) {
await fail(`**网站头像链接**返回了 HTTP ${r2.status},头像加载不出来。`);
process.exit(0);
}
} catch {
await fail(`**网站头像链接** ${avatar} 无法访问。`);
process.exit(0);
}
// 5. 反链检查:友链页面必须包含本站链接
let html = "";
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
const r3 = await fetch(linkpage, {
redirect: "follow",
signal: ctrl.signal,
headers: { "user-agent": "Mozilla/5.0 (compatible; link-checker/1.0)" },
});
clearTimeout(t);
html = await r3.text();
} catch {
await fail(`**友链页面链接** ${linkpage} 无法访问(连接超时或 DNS 解析失败)。`);
process.exit(0);
}
if (!SITE_ALIASES.some((host) => html.includes(host))) {
await fail(
`**友链页面链接** ${linkpage} 没有发现指向本站(${SITE})的链接,\n` +
"请在自己的友链页添加本站友链后再来申请。",
);
process.exit(0);
}
// 6. 去重:可访问链接是否已存在
if (existsSync(LINK_YAML)) {
const existing = readFileSync(LINK_YAML, "utf8");
if (existing.includes(url) || existing.includes(hostnameOf(url))) {
await ok(`友链 \`${name}\` 已经在列表里了,无需重复添加。`);
await gh("PATCH", `/repos/${repo}/issues/${issueNumber}`, { state: "closed" });
process.exit(0);
}
}
// 7. 追加到 link.yaml
const yamlName = yamlQuote(name);
const yamlUrl = yamlQuote(url);
const yamlAvatar = yamlQuote(avatar);
const yamlDesc = yamlQuote(desc);
const block =
`- name: ${yamlName}\n` +
` url: ${yamlUrl}\n` +
` avatar: ${yamlAvatar}\n` +
` desc: ${yamlDesc}\n`;
// 每个条目之间必须空一行,前面保证恰好一个空行
let existing = existsSync(LINK_YAML) ? readFileSync(LINK_YAML, "utf8") : "";
if (existing && !existing.endsWith("\n\n")) {
if (existing.endsWith("\n")) existing += "\n";
else existing += "\n\n";
}
const content = existing + block;
writeFileSync(LINK_YAML, content);
await ok(
`友链 \`${name}\` 已添加!\n\n本 Issue 会自动关闭,网站构建后即可在 [友链页](https://${SITE}/link/) 看到。`,
);
await gh("PATCH", `/repos/${repo}/issues/${issueNumber}`, { state: "closed" });
process.stdout.write("::notice::link added\n");
}
function yamlQuote(value) {
const v = String(value ?? "");
// 简单安全判断:避免 YAML 特殊字符解析出错
if (/[:#\[\]{}&*!|>'"%@`,?\s]/.test(v) || v === "" || /^\s|\s$/.test(v)) {
return JSON.stringify(v);
}
return v;
}
main().catch((e) => {
console.error(e);
process.exit(1);
});