Skip to content

Commit 63e63bf

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 63e63bf

2 files changed

Lines changed: 389 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: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
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+
// 支持 docx URL 和 wiki URL
101+
async function extractDocId(url, token) {
102+
// 独立 docx 文档
103+
const docxMatch = url.match(/\/docx\/([a-zA-Z0-9]+)/);
104+
if (docxMatch) return docxMatch[1];
105+
106+
// wiki 知识库文档
107+
const wikiMatch = url.match(/\/wiki\/([a-zA-Z0-9]+)/);
108+
if (wikiMatch) {
109+
const wikiToken = wikiMatch[1];
110+
console.log(` 检测到 Wiki 文档,正在获取 document_id...`);
111+
const data = await getJson(
112+
`https://open.feishu.cn/open-apis/wiki/v2/spaces/get_node?token=${wikiToken}`,
113+
{ 'Authorization': `Bearer ${token}` }
114+
);
115+
if (data.code !== 0) {
116+
throw new Error(`获取 Wiki 文档信息失败: ${data.msg}`);
117+
}
118+
const docId = data.data.node.obj_token;
119+
console.log(` Wiki document_id: ${docId}`);
120+
return docId;
121+
}
122+
123+
throw new Error('无法从 URL 中提取文档 ID,请确认是新版 Docx 或 Wiki 文档链接');
124+
}
125+
126+
// 生成 URL 友好的 slug
127+
function slugify(text) {
128+
return text
129+
.toLowerCase()
130+
.replace(/[^\w\s-]/g, '-')
131+
.replace(/\s+/g, '-')
132+
.replace(/-+/g, '-')
133+
.replace(/^-|-$/g, '')
134+
.substring(0, 50);
135+
}
136+
137+
// 获取飞书 tenant_access_token
138+
async function getTenantAccessToken(appId, appSecret) {
139+
const data = await postJson(
140+
'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal',
141+
{ app_id: appId, app_secret: appSecret }
142+
);
143+
if (data.code !== 0) {
144+
throw new Error(`获取 token 失败: ${data.msg}`);
145+
}
146+
return data.tenant_access_token;
147+
}
148+
149+
// 获取文档元数据
150+
async function getDocumentMeta(docId, token) {
151+
const data = await getJson(
152+
`https://open.feishu.cn/open-apis/docx/v1/documents/${docId}`,
153+
{ 'Authorization': `Bearer ${token}` }
154+
);
155+
if (data.code !== 0) {
156+
throw new Error(`获取文档元数据失败: ${data.msg}`);
157+
}
158+
return data.data.document;
159+
}
160+
161+
// 获取文档 raw_content(Markdown)
162+
async function getRawContent(docId, token) {
163+
const data = await getJson(
164+
`https://open.feishu.cn/open-apis/docx/v1/documents/${docId}/raw_content`,
165+
{ 'Authorization': `Bearer ${token}` }
166+
);
167+
if (data.code !== 0) {
168+
throw new Error(`获取文档内容失败: ${data.msg}`);
169+
}
170+
return data.data.content;
171+
}
172+
173+
// 处理图片:下载并替换路径
174+
async function processImages(markdown, token, slug) {
175+
const imgDir = path.join('articles', 'images', slug);
176+
fs.mkdirSync(imgDir, { recursive: true });
177+
178+
const imgRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
179+
const images = [];
180+
let match;
181+
182+
while ((match = imgRegex.exec(markdown)) !== null) {
183+
images.push({ alt: match[1], url: match[2], index: images.length });
184+
}
185+
186+
let processedMarkdown = markdown;
187+
let successCount = 0;
188+
let failCount = 0;
189+
190+
for (const img of images) {
191+
const ext = path.extname(new URL(img.url).pathname) || '.png';
192+
const filename = `image-${img.index + 1}${ext}`;
193+
const localPath = path.join(imgDir, filename);
194+
const relativePath = `/articles/images/${slug}/${filename}`;
195+
196+
try {
197+
const isFeishuUrl = img.url.includes('feishu.cn') || img.url.includes('larksuite.com');
198+
const headers = isFeishuUrl ? { 'Authorization': `Bearer ${token}` } : {};
199+
await downloadFile(img.url, localPath, headers);
200+
processedMarkdown = processedMarkdown.replace(img.url, relativePath);
201+
successCount++;
202+
console.log(` ✅ 图片已下载: ${filename}`);
203+
} catch (err) {
204+
failCount++;
205+
console.warn(` ⚠️ 图片下载失败 (${img.url}): ${err.message}`);
206+
}
207+
}
208+
209+
// 清理空图片目录
210+
if (successCount === 0 && fs.existsSync(imgDir)) {
211+
const files = fs.readdirSync(imgDir);
212+
if (files.length === 0) {
213+
fs.rmdirSync(imgDir);
214+
}
215+
}
216+
217+
return { markdown: processedMarkdown, successCount, failCount, imgDir };
218+
}
219+
220+
// 创建 Pull Request
221+
async function createPullRequest(branch, title, body) {
222+
const data = await postJson(
223+
`https://api.github.com/repos/${REPO}/pulls`,
224+
{
225+
title: `feat: publish article "${title}"`,
226+
head: branch,
227+
base: 'master',
228+
body
229+
},
230+
{
231+
'Authorization': `token ${GH_TOKEN}`,
232+
'Accept': 'application/vnd.github.v3+json'
233+
}
234+
);
235+
if (data.message) {
236+
throw new Error(`创建 PR 失败: ${data.message}`);
237+
}
238+
return data;
239+
}
240+
241+
// 主流程
242+
async function main() {
243+
console.log('🚀 开始发布飞书文档...\n');
244+
245+
// 1. 获取 token(Wiki 文档需要先有 token 才能解析 document_id)
246+
console.log('🔑 获取飞书访问令牌...');
247+
const token = await getTenantAccessToken(FEISHU_APP_ID, FEISHU_APP_SECRET);
248+
console.log(' ✅ 获取成功\n');
249+
250+
// 2. 提取 docId
251+
console.log('📎 解析文档链接...');
252+
const docId = await extractDocId(INPUT_URL, token);
253+
console.log(` 文档 ID: ${docId}\n`);
254+
255+
// 3. 获取文档元数据
256+
console.log('📄 获取文档元数据...');
257+
const meta = await getDocumentMeta(docId, token);
258+
console.log(` 文档标题: ${meta.title || '(无)'}\n`);
259+
260+
// 4. 获取文档内容
261+
console.log('📝 获取文档内容...');
262+
let markdown = await getRawContent(docId, token);
263+
console.log(` 内容长度: ${markdown.length} 字符\n`);
264+
265+
// 5. 处理图片
266+
console.log('🖼️ 处理图片...');
267+
const slug = slugify(INPUT_TITLE || meta.title || docId);
268+
const { markdown: processedMd, successCount, failCount } = await processImages(markdown, token, slug);
269+
console.log(` 成功: ${successCount}, 失败: ${failCount}\n`);
270+
271+
// 6. 确定最终元数据
272+
const title = INPUT_TITLE || meta.title || 'Untitled';
273+
const author = INPUT_AUTHOR || '';
274+
const tags = INPUT_TAGS ? INPUT_TAGS.split(',').map(t => t.trim()).filter(Boolean) : [];
275+
const date = new Date().toISOString().split('T')[0];
276+
const filename = `${date}-${slug}.md`;
277+
const filePath = path.join('articles', filename);
278+
279+
console.log('📋 文章信息:');
280+
console.log(` 标题: ${title}`);
281+
console.log(` 作者: ${author || '(空)'}`);
282+
console.log(` 标签: ${tags.length > 0 ? tags.join(', ') : '(空)'}`);
283+
console.log(` 文件名: ${filename}\n`);
284+
285+
// 7. 生成文件
286+
console.log('💾 生成文章文件...');
287+
const frontmatter = `---
288+
title: "${title.replace(/"/g, '\\"')}"
289+
description: ""
290+
date: "${date}"
291+
author: "${author.replace(/"/g, '\\"')}"
292+
tags: [${tags.map(t => `"${t.replace(/"/g, '\\"')}"`).join(', ')}]
293+
---
294+
295+
${processedMd}`;
296+
297+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
298+
fs.writeFileSync(filePath, frontmatter, 'utf-8');
299+
console.log(` ✅ 已保存: ${filePath}\n`);
300+
301+
// 8. Git 操作
302+
console.log('🔀 创建分支并提交...');
303+
const branch = `feat/article-${date}-${slug}`;
304+
execSync('git config user.name "GitHub Actions"');
305+
execSync('git config user.email "actions@github.com"');
306+
execSync(`git checkout -b ${branch}`);
307+
execSync('git add .');
308+
execSync(`git commit -m "feat: publish article ${title}"`);
309+
execSync(`git push origin ${branch}`);
310+
console.log(` ✅ 分支已推送: ${branch}\n`);
311+
312+
// 9. 创建 PR
313+
console.log('📬 创建 Pull Request...');
314+
const prBody = `## 新文章发布
315+
316+
| 字段 | 内容 |
317+
|------|------|
318+
| **来源** | [飞书文档](${INPUT_URL}) |
319+
| **标题** | ${title} |
320+
| **作者** | ${author || '-'} |
321+
| **标签** | ${tags.length > 0 ? tags.join(', ') : '-'} |
322+
| **日期** | ${date} |
323+
| **图片** | ${successCount} 张成功${failCount > 0 ? `, ${failCount} 张失败` : ''} |
324+
325+
> 由 GitHub Actions 自动生成。`;
326+
327+
const pr = await createPullRequest(branch, title, prBody);
328+
console.log(` ✅ PR 创建成功!`);
329+
console.log(` 链接: ${pr.html_url}\n`);
330+
console.log('🎉 完成! 请 review 并 merge PR 后手动触发部署。');
331+
}
332+
333+
main().catch(err => {
334+
console.error('\n❌ 错误:', err.message);
335+
if (err.stack) console.error(err.stack);
336+
process.exit(1);
337+
});

0 commit comments

Comments
 (0)