You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

218 lines
6.0 KiB
TypeScript

import { consola } from 'consola';
import { writeJSONSync } from 'fs-extra';
import matter from 'gray-matter';
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import pMap from 'p-map';
import { uploader } from './uploader';
import {
changelogIndex,
changelogIndexPath,
extractHttpsLinks,
fetchImageAsFile,
mergeAndDeduplicateArrays,
posts,
root,
} from './utils';
// 定义常量
const GITHUB_CDN = 'https://github.com/lobehub/lobe-chat/assets/';
const CHECK_CDN = [
'https://cdn.nlark.com/yuque/0/',
'https://s.imtccdn.com/',
'https://oss.home.imtc.top/',
'https://www.anthropic.com/_next/image',
'https://miro.medium.com/v2/',
'https://images.unsplash.com/',
'https://github.com/user-attachments/assets',
];
const CACHE_FILE = resolve(root, 'docs', '.cdn.cache.json');
class ImageCDNUploader {
private cache: { [link: string]: string } = {};
constructor() {
this.loadCache();
}
// 从文件加载缓存数据
private loadCache() {
try {
this.cache = JSON.parse(readFileSync(CACHE_FILE, 'utf8'));
} catch (error) {
consola.error('Failed to load cache', error);
}
}
// 将缓存数据写入文件
private writeCache() {
try {
writeFileSync(CACHE_FILE, JSON.stringify(this.cache, null, 2));
} catch (error) {
consola.error('Failed to write cache', error);
}
}
// 收集所有的图片链接
private collectImageLinks(): string[] {
const links: string[][] = posts.map((post) => {
const mdx = readFileSync(post, 'utf8');
const { content, data } = matter(mdx);
let inlineLinks: string[] = extractHttpsLinks(content);
// 添加特定字段中的图片链接
if (data?.image) inlineLinks.push(data.image);
if (data?.seo?.image) inlineLinks.push(data.seo.image);
// 过滤出有效的 CDN 链接
return inlineLinks.filter(
(link) =>
(link.startsWith(GITHUB_CDN) || CHECK_CDN.some((cdn) => link.startsWith(cdn))) &&
!this.cache[link],
);
});
const communityLinks = changelogIndex.community
.map((post) => post.image)
.filter(
(link) =>
link &&
(link.startsWith(GITHUB_CDN) || CHECK_CDN.some((cdn) => link.startsWith(cdn))) &&
!this.cache[link],
) as string[];
const cloudLinks = changelogIndex.cloud
.map((post) => post.image)
.filter(
(link) =>
link &&
(link.startsWith(GITHUB_CDN) || CHECK_CDN.some((cdn) => link.startsWith(cdn))) &&
!this.cache[link],
) as string[];
// 合并和去重链接数组
return mergeAndDeduplicateArrays(links.flat().concat(communityLinks, cloudLinks));
}
// 上传图片到 CDN
private async uploadImagesToCDN(links: string[]) {
const cdnLinks: { [link: string]: string } = {};
await pMap(links, async (link) => {
consola.start('Uploading image to CDN', link);
const file = await fetchImageAsFile(link, 1600);
if (!file) {
consola.error('Failed to fetch image as file', link);
return;
}
const cdnUrl = await this.uploadFileToCDN(file, link);
if (cdnUrl) {
consola.success(link, '>>>', cdnUrl);
cdnLinks[link] = cdnUrl.replaceAll(process.env.DOC_S3_PUBLIC_DOMAIN || '', '');
}
});
// 更新缓存
this.cache = { ...this.cache, ...cdnLinks };
this.writeCache();
}
// 根据不同的 CDN 来处理文件上传
private async uploadFileToCDN(file: File, link: string): Promise<string | undefined> {
if (link.startsWith(GITHUB_CDN)) {
const filename = link.replaceAll(GITHUB_CDN, '');
return uploader(file, filename);
} else if (CHECK_CDN.some((cdn) => link.startsWith(cdn))) {
const buffer = await file.arrayBuffer();
const hash = createHash('md5').update(Buffer.from(buffer)).digest('hex');
return uploader(file, hash);
}
return;
}
// 替换文章中的图片链接
private replaceLinksInPosts() {
let count = 0;
for (const post of posts) {
const mdx = readFileSync(post, 'utf8');
let { content, data } = matter(mdx);
const inlineLinks = extractHttpsLinks(content);
for (const link of inlineLinks) {
if (this.cache[link]) {
content = content.replaceAll(link, this.cache[link]);
count++;
}
}
// 更新特定字段的图片链接
if (data['image'] && this.cache[data['image']]) {
data['image'] = this.cache[data['image']];
count++;
}
if (data['seo']?.['image'] && this.cache[data['seo']?.['image']]) {
data['seo']['image'] = this.cache[data['seo']['image']];
count++;
}
writeFileSync(post, matter.stringify(content, data));
}
consola.success(`${count} images have been uploaded to CDN and links have been replaced`);
}
private replaceLinksInChangelogIndex() {
let count = 0;
changelogIndex.community = changelogIndex.community.map((post) => {
if (!post.image) return post;
count++;
return {
...post,
image: this.cache[post.image] || post.image,
};
});
changelogIndex.cloud = changelogIndex.cloud.map((post) => {
if (!post.image) return post;
count++;
return {
...post,
image: this.cache[post.image] || post.image,
};
});
writeJSONSync(changelogIndexPath, changelogIndex, { spaces: 2 });
consola.success(
`${count} changelog index images have been uploaded to CDN and links have been replaced`,
);
}
// 运行上传过程
async run() {
const links = this.collectImageLinks();
if (links.length > 0) {
consola.info("Found images that haven't been uploaded to CDN:");
consola.info(links);
await this.uploadImagesToCDN(links);
} else {
consola.info('No new images to upload.');
}
}
}
// 实例化并运行
const instance = new ImageCDNUploader();
instance.run();