Skip to content

Commit ae7afa3

Browse files
committed
feat: add GitHub Actions workflow for publishing Feishu articles
- Add workflow_dispatch triggered CI workflow - Add script to fetch Feishu docx content, download images, generate frontmatter, and create a PR - Title/author/tags are optional, falling back to Feishu doc metadata
1 parent 25d7a15 commit ae7afa3

2 files changed

Lines changed: 369 additions & 0 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: Publish Feishu Article
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
feishu_url:
7+
description: '飞书文档链接(新版 Docx)'
8+
required: true
9+
type: string
10+
title:
11+
description: '文章标题(留空从文档提取)'
12+
required: false
13+
type: string
14+
author:
15+
description: '作者(留空为空)'
16+
required: false
17+
type: string
18+
tags:
19+
description: '标签,逗号分隔(留空则无)'
20+
required: false
21+
type: string
22+
23+
permissions:
24+
contents: write
25+
pull-requests: write
26+
27+
jobs:
28+
publish:
29+
runs-on: ubuntu-latest
30+
steps:
31+
- name: Checkout
32+
uses: actions/checkout@v4
33+
with:
34+
fetch-depth: 0
35+
token: ${{ secrets.GH_TOKEN }}
36+
37+
- name: Setup Node.js
38+
uses: actions/setup-node@v4
39+
with:
40+
node-version: '20'
41+
42+
- name: Import Feishu Article
43+
env:
44+
FEISHU_APP_ID: ${{ secrets.FEISHU_APP_ID }}
45+
FEISHU_APP_SECRET: ${{ secrets.FEISHU_APP_SECRET }}
46+
INPUT_URL: ${{ github.event.inputs.feishu_url }}
47+
INPUT_TITLE: ${{ github.event.inputs.title }}
48+
INPUT_AUTHOR: ${{ github.event.inputs.author }}
49+
INPUT_TAGS: ${{ github.event.inputs.tags }}
50+
GH_TOKEN: ${{ secrets.GH_TOKEN }}
51+
GITHUB_REPOSITORY: ${{ github.repository }}
52+
run: node scripts/import-feishu-article.js

scripts/import-feishu-article.js

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
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

Comments
 (0)