|
| 1 | +const https = require('https'); |
| 2 | +const fs = require('fs'); |
| 3 | +const path = require('path'); |
| 4 | +const { execSync } = require('child_process'); |
| 5 | + |
| 6 | +// 配置 |
| 7 | +const FEISHU_APP_ID = process.env.FEISHU_APP_ID; |
| 8 | +const FEISHU_APP_SECRET = process.env.FEISHU_APP_SECRET; |
| 9 | +const INPUT_URL = process.env.INPUT_URL; |
| 10 | +const INPUT_TITLE = process.env.INPUT_TITLE || ''; |
| 11 | +const INPUT_AUTHOR = process.env.INPUT_AUTHOR || ''; |
| 12 | +const INPUT_TAGS = process.env.INPUT_TAGS || ''; |
| 13 | +const GH_TOKEN = process.env.GH_TOKEN; |
| 14 | +const REPO = process.env.GITHUB_REPOSITORY; |
| 15 | + |
| 16 | +// 工具函数:HTTP 请求 |
| 17 | +function request(options, body = null) { |
| 18 | + return new Promise((resolve, reject) => { |
| 19 | + const req = https.request(options, (res) => { |
| 20 | + let data = ''; |
| 21 | + res.on('data', chunk => data += chunk); |
| 22 | + res.on('end', () => { |
| 23 | + try { |
| 24 | + resolve(JSON.parse(data)); |
| 25 | + } catch { |
| 26 | + resolve(data); |
| 27 | + } |
| 28 | + }); |
| 29 | + }); |
| 30 | + req.on('error', reject); |
| 31 | + if (body) req.write(body); |
| 32 | + req.end(); |
| 33 | + }); |
| 34 | +} |
| 35 | + |
| 36 | +async function postJson(url, body, headers = {}) { |
| 37 | + const parsed = new URL(url); |
| 38 | + const options = { |
| 39 | + hostname: parsed.hostname, |
| 40 | + path: parsed.pathname + parsed.search, |
| 41 | + method: 'POST', |
| 42 | + headers: { |
| 43 | + 'Content-Type': 'application/json', |
| 44 | + ...headers |
| 45 | + } |
| 46 | + }; |
| 47 | + return request(options, JSON.stringify(body)); |
| 48 | +} |
| 49 | + |
| 50 | +async function getJson(url, headers = {}) { |
| 51 | + const parsed = new URL(url); |
| 52 | + const options = { |
| 53 | + hostname: parsed.hostname, |
| 54 | + path: parsed.pathname + parsed.search, |
| 55 | + method: 'GET', |
| 56 | + headers |
| 57 | + }; |
| 58 | + return request(options); |
| 59 | +} |
| 60 | + |
| 61 | +// 下载文件 |
| 62 | +function downloadFile(url, destPath, headers = {}) { |
| 63 | + return new Promise((resolve, reject) => { |
| 64 | + const parsed = new URL(url); |
| 65 | + const options = { |
| 66 | + hostname: parsed.hostname, |
| 67 | + path: parsed.pathname + parsed.search, |
| 68 | + method: 'GET', |
| 69 | + headers |
| 70 | + }; |
| 71 | + const file = fs.createWriteStream(destPath); |
| 72 | + https.get(options, (res) => { |
| 73 | + if (res.statusCode === 302 || res.statusCode === 301) { |
| 74 | + // 处理重定向 |
| 75 | + file.close(); |
| 76 | + fs.unlinkSync(destPath); |
| 77 | + downloadFile(res.headers.location, destPath, headers).then(resolve).catch(reject); |
| 78 | + return; |
| 79 | + } |
| 80 | + if (res.statusCode !== 200) { |
| 81 | + file.close(); |
| 82 | + fs.unlinkSync(destPath); |
| 83 | + reject(new Error(`Download failed: ${res.statusCode}`)); |
| 84 | + return; |
| 85 | + } |
| 86 | + res.pipe(file); |
| 87 | + file.on('finish', () => { |
| 88 | + file.close(); |
| 89 | + resolve(); |
| 90 | + }); |
| 91 | + }).on('error', (err) => { |
| 92 | + file.close(); |
| 93 | + if (fs.existsSync(destPath)) fs.unlinkSync(destPath); |
| 94 | + reject(err); |
| 95 | + }); |
| 96 | + }); |
| 97 | +} |
| 98 | + |
| 99 | +// 从 URL 提取 document_id |
| 100 | +function extractDocId(url) { |
| 101 | + const match = url.match(/\/docx\/([a-zA-Z0-9]+)/); |
| 102 | + if (match) return match[1]; |
| 103 | + throw new Error('无法从 URL 中提取文档 ID,请确认是新版 Docx 文档链接'); |
| 104 | +} |
| 105 | + |
| 106 | +// 生成 URL 友好的 slug |
| 107 | +function slugify(text) { |
| 108 | + return text |
| 109 | + .toLowerCase() |
| 110 | + .replace(/[^\w\s一-龥]/g, '-') |
| 111 | + .replace(/\s+/g, '-') |
| 112 | + .replace(/-+/g, '-') |
| 113 | + .replace(/^-|-$/g, '') |
| 114 | + .substring(0, 50); |
| 115 | +} |
| 116 | + |
| 117 | +// 获取飞书 tenant_access_token |
| 118 | +async function getTenantAccessToken(appId, appSecret) { |
| 119 | + const data = await postJson( |
| 120 | + 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', |
| 121 | + { app_id: appId, app_secret: appSecret } |
| 122 | + ); |
| 123 | + if (data.code !== 0) { |
| 124 | + throw new Error(`获取 token 失败: ${data.msg}`); |
| 125 | + } |
| 126 | + return data.tenant_access_token; |
| 127 | +} |
| 128 | + |
| 129 | +// 获取文档元数据 |
| 130 | +async function getDocumentMeta(docId, token) { |
| 131 | + const data = await getJson( |
| 132 | + `https://open.feishu.cn/open-apis/docx/v1/documents/${docId}`, |
| 133 | + { 'Authorization': `Bearer ${token}` } |
| 134 | + ); |
| 135 | + if (data.code !== 0) { |
| 136 | + throw new Error(`获取文档元数据失败: ${data.msg}`); |
| 137 | + } |
| 138 | + return data.data.document; |
| 139 | +} |
| 140 | + |
| 141 | +// 获取文档 raw_content(Markdown) |
| 142 | +async function getRawContent(docId, token) { |
| 143 | + const data = await getJson( |
| 144 | + `https://open.feishu.cn/open-apis/docx/v1/documents/${docId}/raw_content`, |
| 145 | + { 'Authorization': `Bearer ${token}` } |
| 146 | + ); |
| 147 | + if (data.code !== 0) { |
| 148 | + throw new Error(`获取文档内容失败: ${data.msg}`); |
| 149 | + } |
| 150 | + return data.data.content; |
| 151 | +} |
| 152 | + |
| 153 | +// 处理图片:下载并替换路径 |
| 154 | +async function processImages(markdown, token, slug) { |
| 155 | + const imgDir = path.join('articles', 'images', slug); |
| 156 | + fs.mkdirSync(imgDir, { recursive: true }); |
| 157 | + |
| 158 | + const imgRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; |
| 159 | + const images = []; |
| 160 | + let match; |
| 161 | + |
| 162 | + while ((match = imgRegex.exec(markdown)) !== null) { |
| 163 | + images.push({ alt: match[1], url: match[2], index: images.length }); |
| 164 | + } |
| 165 | + |
| 166 | + let processedMarkdown = markdown; |
| 167 | + let successCount = 0; |
| 168 | + let failCount = 0; |
| 169 | + |
| 170 | + for (const img of images) { |
| 171 | + const ext = path.extname(new URL(img.url).pathname) || '.png'; |
| 172 | + const filename = `image-${img.index + 1}${ext}`; |
| 173 | + const localPath = path.join(imgDir, filename); |
| 174 | + const relativePath = `/articles/images/${slug}/${filename}`; |
| 175 | + |
| 176 | + try { |
| 177 | + const isFeishuUrl = img.url.includes('feishu.cn') || img.url.includes('larksuite.com'); |
| 178 | + const headers = isFeishuUrl ? { 'Authorization': `Bearer ${token}` } : {}; |
| 179 | + await downloadFile(img.url, localPath, headers); |
| 180 | + processedMarkdown = processedMarkdown.replace(img.url, relativePath); |
| 181 | + successCount++; |
| 182 | + console.log(` ✅ 图片已下载: ${filename}`); |
| 183 | + } catch (err) { |
| 184 | + failCount++; |
| 185 | + console.warn(` ⚠️ 图片下载失败 (${img.url}): ${err.message}`); |
| 186 | + } |
| 187 | + } |
| 188 | + |
| 189 | + // 清理空图片目录 |
| 190 | + if (successCount === 0 && fs.existsSync(imgDir)) { |
| 191 | + const files = fs.readdirSync(imgDir); |
| 192 | + if (files.length === 0) { |
| 193 | + fs.rmdirSync(imgDir); |
| 194 | + } |
| 195 | + } |
| 196 | + |
| 197 | + return { markdown: processedMarkdown, successCount, failCount, imgDir }; |
| 198 | +} |
| 199 | + |
| 200 | +// 创建 Pull Request |
| 201 | +async function createPullRequest(branch, title, body) { |
| 202 | + const data = await postJson( |
| 203 | + `https://api.github.com/repos/${REPO}/pulls`, |
| 204 | + { |
| 205 | + title: `feat: publish article "${title}"`, |
| 206 | + head: branch, |
| 207 | + base: 'master', |
| 208 | + body |
| 209 | + }, |
| 210 | + { |
| 211 | + 'Authorization': `token ${GH_TOKEN}`, |
| 212 | + 'Accept': 'application/vnd.github.v3+json' |
| 213 | + } |
| 214 | + ); |
| 215 | + if (data.message) { |
| 216 | + throw new Error(`创建 PR 失败: ${data.message}`); |
| 217 | + } |
| 218 | + return data; |
| 219 | +} |
| 220 | + |
| 221 | +// 主流程 |
| 222 | +async function main() { |
| 223 | + console.log('🚀 开始发布飞书文档...\n'); |
| 224 | + |
| 225 | + // 1. 提取 docId |
| 226 | + console.log('📎 解析文档链接...'); |
| 227 | + const docId = extractDocId(INPUT_URL); |
| 228 | + console.log(` 文档 ID: ${docId}\n`); |
| 229 | + |
| 230 | + // 2. 获取 token |
| 231 | + console.log('🔑 获取飞书访问令牌...'); |
| 232 | + const token = await getTenantAccessToken(FEISHU_APP_ID, FEISHU_APP_SECRET); |
| 233 | + console.log(' ✅ 获取成功\n'); |
| 234 | + |
| 235 | + // 3. 获取文档元数据 |
| 236 | + console.log('📄 获取文档元数据...'); |
| 237 | + const meta = await getDocumentMeta(docId, token); |
| 238 | + console.log(` 文档标题: ${meta.title || '(无)'}\n`); |
| 239 | + |
| 240 | + // 4. 获取文档内容 |
| 241 | + console.log('📝 获取文档内容...'); |
| 242 | + let markdown = await getRawContent(docId, token); |
| 243 | + console.log(` 内容长度: ${markdown.length} 字符\n`); |
| 244 | + |
| 245 | + // 5. 处理图片 |
| 246 | + console.log('🖼️ 处理图片...'); |
| 247 | + const slug = slugify(INPUT_TITLE || meta.title || docId); |
| 248 | + const { markdown: processedMd, successCount, failCount } = await processImages(markdown, token, slug); |
| 249 | + console.log(` 成功: ${successCount}, 失败: ${failCount}\n`); |
| 250 | + |
| 251 | + // 6. 确定最终元数据 |
| 252 | + const title = INPUT_TITLE || meta.title || 'Untitled'; |
| 253 | + const author = INPUT_AUTHOR || ''; |
| 254 | + const tags = INPUT_TAGS ? INPUT_TAGS.split(',').map(t => t.trim()).filter(Boolean) : []; |
| 255 | + const date = new Date().toISOString().split('T')[0]; |
| 256 | + const filename = `${date}-${slug}.md`; |
| 257 | + const filePath = path.join('articles', filename); |
| 258 | + |
| 259 | + console.log('📋 文章信息:'); |
| 260 | + console.log(` 标题: ${title}`); |
| 261 | + console.log(` 作者: ${author || '(空)'}`); |
| 262 | + console.log(` 标签: ${tags.length > 0 ? tags.join(', ') : '(空)'}`); |
| 263 | + console.log(` 文件名: ${filename}\n`); |
| 264 | + |
| 265 | + // 7. 生成文件 |
| 266 | + console.log('💾 生成文章文件...'); |
| 267 | + const frontmatter = `--- |
| 268 | +title: "${title.replace(/"/g, '\\"')}" |
| 269 | +description: "" |
| 270 | +date: "${date}" |
| 271 | +author: "${author.replace(/"/g, '\\"')}" |
| 272 | +tags: [${tags.map(t => `"${t.replace(/"/g, '\\"')}"`).join(', ')}] |
| 273 | +--- |
| 274 | +
|
| 275 | +${processedMd}`; |
| 276 | + |
| 277 | + fs.mkdirSync(path.dirname(filePath), { recursive: true }); |
| 278 | + fs.writeFileSync(filePath, frontmatter, 'utf-8'); |
| 279 | + console.log(` ✅ 已保存: ${filePath}\n`); |
| 280 | + |
| 281 | + // 8. Git 操作 |
| 282 | + console.log('🔀 创建分支并提交...'); |
| 283 | + const branch = `feat/article-${date}-${slug}`; |
| 284 | + execSync('git config user.name "GitHub Actions"'); |
| 285 | + execSync('git config user.email "actions@github.com"'); |
| 286 | + execSync(`git checkout -b ${branch}`); |
| 287 | + execSync('git add .'); |
| 288 | + execSync(`git commit -m "feat: publish article ${title}"`); |
| 289 | + execSync(`git push origin ${branch}`); |
| 290 | + console.log(` ✅ 分支已推送: ${branch}\n`); |
| 291 | + |
| 292 | + // 9. 创建 PR |
| 293 | + console.log('📬 创建 Pull Request...'); |
| 294 | + const prBody = `## 新文章发布 |
| 295 | +
|
| 296 | +| 字段 | 内容 | |
| 297 | +|------|------| |
| 298 | +| **来源** | [飞书文档](${INPUT_URL}) | |
| 299 | +| **标题** | ${title} | |
| 300 | +| **作者** | ${author || '-'} | |
| 301 | +| **标签** | ${tags.length > 0 ? tags.join(', ') : '-'} | |
| 302 | +| **日期** | ${date} | |
| 303 | +| **图片** | ${successCount} 张成功${failCount > 0 ? `, ${failCount} 张失败` : ''} | |
| 304 | +
|
| 305 | +> 由 GitHub Actions 自动生成。`; |
| 306 | + |
| 307 | + const pr = await createPullRequest(branch, title, prBody); |
| 308 | + console.log(` ✅ PR 创建成功!`); |
| 309 | + console.log(` 链接: ${pr.html_url}\n`); |
| 310 | + console.log('🎉 完成! 请 review 并 merge PR 后手动触发部署。'); |
| 311 | +} |
| 312 | + |
| 313 | +main().catch(err => { |
| 314 | + console.error('\n❌ 错误:', err.message); |
| 315 | + if (err.stack) console.error(err.stack); |
| 316 | + process.exit(1); |
| 317 | +}); |
0 commit comments