현재 다니고 있는 회사에서 PM/PD 분들이 Notion을 활용하여 사용자 가이드와 업데이트 노트를 관리하고 계시는데, 이 노션문서들을 html로 내보내기 후 S3에 배포하여 웹사이트에서 보여질 수 있도록 하고 있다. (내보내기한 tree구조의 파일 용량은 80mb에 달한다.)
문제는 S3에 배포된 문서의 <details>
태그가 모두 열려 있다는 것이다. 분명히 닫아놓은 상태로 문서수정을 마쳤는데 노션의 "html로 내보내기" 기능을 사용하면 모든 디테일태그가 열려 있는 것이었다.
(노션의 내보내기 기능은 <details>
태그를 모두 열어 놓는구나...PDF때문에 그런건가?)
결국 어느 날, 디테일 태그를 모두 닫아달라는 요구사항이 들어왔다.
배포할 때 마다 80mb에 달하는 tree구조의 html파일들의 <details>
태그를 찾아 'open' 속성을 지우고 s3에 업로드 하는 것은 너무 비효율적인 일이었다.
먼저, <details>
태그를 찾아 'open' 속성을 지우는 스크립트를 개발해서 매 배포때마다 수동으로 스크립트를 돌리고 나온 결과물을 s3에 업로드 했었다.
배포과정이 복잡해지면 휴먼에러도 발생하고, 신경쓸 것 도 많아지기도 하고, "3번 이상 반복하는 일은 자동화하자" 는 신념이 있는 만큼 이번에도 자동화를 진행했다.
<details>
태그의 'open' 속성을 제거한다. 그 후, 수정된 파일들을 원본 ZIP 파일의 경로 구조와 동일하게 S3에 업로드한다.위 과정을 거치도록 만들었다.
아래처럼 특정 경로에 zip파일이 업로드되면 lambda 함수가 돌도록 트리거를 건다.
트리거되면 아래 lambda 함수의 코드가 돌게된다.
import pkg from 'aws-sdk';
import * as fs from 'fs/promises';
import * as path from 'path';
import extractZip from 'extract-zip';
import cheerio from 'cheerio';
import { pipeline } from 'stream/promises';
import { createWriteStream } from 'fs';
const { S3 } = pkg;
const s3 = new S3();
export const handler = async event => {
const record = event.Records[0].s3;
const bucketName = record.bucket.name;
const objectKey = decodeURIComponent(record.object.key.replace(/\+/g, ' '));
const zipFileName = objectKey.split('/').pop();
// zipFilePath를 여기에서 정의하여 함수 전체에서 접근 가능하게 합니다.
const zipFilePath = path.join('/tmp', zipFileName);
const tmpFolderPath = path.join('/tmp', zipFileName.replace('.zip', ''));
const basePath = objectKey.substring(0, objectKey.lastIndexOf('/') + 1); // 예: "static/Test/"
// ZIP 파일 이름(확장자 제외)을 추가하여 압축 해제된 파일의 기본 경로를 생성합니다.
const zipBaseName = path.basename(objectKey, '.zip'); // "3_5_4"
const fullBasePath = `${basePath}${zipBaseName}/`; // "static/Test/3_5_4/"
try {
const { Body } = await s3.getObject({ Bucket: bucketName, Key: objectKey }).promise();
// 스트림을 파일로 쓰기
if (Body instanceof Buffer) {
await fs.writeFile(zipFilePath, Body);
} else {
await pipeline(Body, createWriteStream(zipFilePath));
}
await extractZip(zipFilePath, { dir: tmpFolderPath });
await processDirectory(tmpFolderPath, bucketName, tmpFolderPath, fullBasePath);
console.log('All HTML files processed and uploaded successfully.');
} catch (error) {
console.error(`Error processing files: ${error}`);
throw error;
} finally {
// 임시 파일 및 폴더 정리
await fs.rm(tmpFolderPath, { recursive: true, force: true }).catch(console.error);
await fs.rm(zipFilePath, { force: true }).catch(console.error);
// 원본 ZIP 파일을 S3에서 삭제
await s3
.deleteObject({
Bucket: bucketName,
Key: objectKey,
})
.promise()
.then(() => {
console.log(`Deleted original zip: ${objectKey}`);
})
.catch(console.error);
}
};
async function processDirectory(directory, bucketName, baseFolderPath, fullBasePath) {
const entries = await fs.readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
await processDirectory(fullPath, bucketName, baseFolderPath, fullBasePath);
} else {
// 모든 파일을 처리하도록 processFile 함수 호출
await processFile(fullPath, bucketName, baseFolderPath, fullBasePath);
}
}
}
async function processFile(filePath, bucketName, baseFolderPath, fullBasePath) {
let fileContent = await fs.readFile(filePath);
// 파일이 HTML인 경우 추가 처리
if (filePath.endsWith('.html')) {
const $ = cheerio.load(fileContent.toString());
$('details').removeAttr('open');
fileContent = Buffer.from($.html());
}
// 파일의 상대 경로를 계산합니다.
const relativePath = path.relative(baseFolderPath, filePath);
// S3에 업로드할 때 사용할 Key 값을 설정합니다.
const s3Key = `${fullBasePath}${relativePath}`;
await s3
.putObject({
Bucket: bucketName,
Key: s3Key,
Body: fileContent,
ContentType: getContentTypeByFile(filePath),
})
.promise();
console.log(`Uploaded: ${s3Key}`);
}
// 파일 확장자에 따라 적절한 Content-Type을 반환하는 함수
function getContentTypeByFile(fileName) {
const extension = path.extname(fileName).toLowerCase();
switch (extension) {
case '.html':
return 'text/html';
case '.css':
return 'text/css';
case '.js':
return 'application/javascript';
case '.png':
return 'image/png';
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.gif':
return 'image/gif';
// 추가적인 파일 유형에 대한 처리를 여기에 포함
default:
return 'application/octet-stream';
}
}