아내가 요즘 경제뉴스에 관심이 많다. 그래서 관련 뉴스들을 보고 다시 정리하는 일을 매일 하고 있다. 옆에서 보면 대단하다고도 생각되고 기특하다고도 생각이 든다.
어느날과 같이 옆에서 뉴스 기사들을 정리하며 블로그에 글을 작성하는 아내를 지켜보고 있는데, 아내가 나를 보면서 이런 말을 하였다.
그래서 다음과 같은 동작을 하는 어플리케이션을 구상해 보기로 하였다.
- 특정 사이트에서 뉴스 기사들을 가져온다.
- 가져온 기사들을 특정 폴더에 문서파일로 저장한다.
- + 가져온 기사들을 생성형 AI를 통해서 좀더 다듬으면 좋을 것 같다.
- + 차후에는 파일저장이 아닌 자동으로 블로그나 NOTION 등에 글이 작성되게 하면 좋겠다.
우선 웹 크롤링이 주된 기능이므로 Node.js를 선택하였다. 그 이유는 다음과 같다.
- Node.js는 비동기 이벤트 기반의 모델을 사용하므로 I/O 작업에 효율적인데, 웹 크롤링은 I/O 중심의 작업이다.
- 비교적 간단하고 빠르게 웹 크롤링 어플리케이션을 만들 수 있을 것으로 기대한다.
- 크롤링을 위한 다양한 라이브러리가 있고 그중 "cheerio" 를 살펴보니 쉽게 사용 가능할 것으로 보였다.
크롤링을 현업에서 해본적은 없지만 다행이 구현을 위한 자료들이 많아서 목표 1, 목표 2의 기능구현은 어렵지 않게 할 수 있었다. 내가 작성해본 코드는 아래와 같다.
const axios = require("axios");
const cheerio = require("cheerio");
const fs = require('fs');
const path = require('path');
const NAVERN_NEWS_URL = "https://m.stock.naver.com/investment/news/flashnews";
const NEWS_SUMMARY_DIRECTORY = "C:/news";
const getHtml = async (url) => {
try {
const html = await axios.get(url);
return html.data; // HTML 내용을 반환
} catch (error) {
console.error(error);
throw new Error('Failed to fetch HTML content');
}
};
const getContent = async (url, idx) => {
try {
const html = await getHtml(url);
const $ = cheerio.load(html);
let content = $('#dic_area').text();
// HTML 태그 제거
content = content.replace(/<[^>]*>?/gm, '');
// 여분의 공백, 탭, 줄바꿈 문자 제거
content = content.replace(/\s+/g, ' ').trim();
return `${idx}번째 기사 \n` + content + `\n\n`;
} catch (error) {
return `getContent에서 ${idx}번째 기사를 가져오는 데 실패 : `, error;
}
}
const createNewsSummaryFile = async (contentList) => {
try {
const currentDate = new Date();
const year = currentDate.getFullYear();
const month = currentDate.getMonth() + 1;
const day = currentDate.getDate();
const filePath = path.join(NEWS_SUMMARY_DIRECTORY, `뉴스요약_${year}-${month}-${day}.txt`);
const writeStream = fs.createWriteStream(filePath, { encoding: 'utf-8' });
contentList.forEach(content => writeStream.write(content));
writeStream.end();
console.log(`파일 생성 및 저장 성공 : ${filePath}`);
} catch (error) {
console.error('createNewsSummaryFile에서 파일 생성 실패 : ', error);
}
};
const extractLinks = async () => {
try {
const html = await getHtml(NAVERN_NEWS_URL);
const $ = cheerio.load(html);
return $('.list a').slice(0, 5).map((index, element) => $(element).attr('href')).get();
} catch (error) {
console.error('extractLinks에서 링크추출 실패 : ', error);
return [];
}
};
const extractLinksAndSaveToFile = async () => {
try {
const links = await extractLinks();
const contentList = [];
let idx = 1;
for (const link of links) {
contentList.push(await getContent(link, idx++));
}
await createNewsSummaryFile(contentList);
} catch (error) {
console.error('extractLinksAndSaveToFile에서 오류 발생 :', error);
}
};
extractLinksAndSaveToFile();
이제 3의 과정을 로직에 추가하여 가져온 글들을 좀더 다듬은 상태로 만들고 싶었다.
다양한 생성형 AI들이 있고, 몇가지를 테스트로 사용해 본 뒤 어떤 AI를 이용하는게 만족스러운 결과물을 기대할 수 있는지 비교해 보려고 하였다. 우선 Gemini API를 이용해보았다.
Gemini Doc의 샘플 코드를 참고한 예제코드는 다음과 같다.
const { GoogleGenerativeAI } = require("@google/generative-ai");
const genAI = new GoogleGenerativeAI(`Gemini_API_KEY`);
async function run(news) {
const model = genAI.getGenerativeModel({ model: "gemini-pro"});
let prompt = `I'm going to input a news article. I'm planning to summarize it and post it on my blog. First, please come up with a suitable title. And remove any text unrelated to the news content. Please summarize the input. Change the tone to a more friendly one like '했어요' instead of '습니다'. Provide the result in Korean.\n`
prompt += news;
const result = await model.generateContent(prompt);
const response = await result.response;
const text = response.text();
console.log(text);
}
const news = `정리 및 요약을 원하는 뉴스기사`;
run(news);
나는 입력도 텍스트이고 출력 또한 텍스트를 원한다. 그렇게 때문에 프롬프트 입력에 텍스트만 포함된 경우 사용하는 gemini-pro 모델을 generateContent 메서드와 함께 사용하여 텍스트 출력을 하는 코드를 작성하였다.
run() 함수 안 prompt 변수에는 내가 원하는 출력을 유도하는 프롬프트를 우선 입력하고, 그 뒤에는 가공을 원하는 입력값을 붙여서 AI에게 던져주어 원하는 결과를 얻을 수 있게 구현하였다.
이제 두 로직을 적당히 섞는다면 목표1, 2, 3이 달성된다. 코드는 아래와 같다.
const axios = require("axios");
const cheerio = require("cheerio");
const fs = require('fs');
const path = require('path');
const { GoogleGenerativeAI } = require("@google/generative-ai");
const genAI = new GoogleGenerativeAI(`Gemini_API_KEY`);
const NAVERN_NEWS_URL = "https://m.stock.naver.com/investment/news/mainnews";
const NEWS_SUMMARY_DIRECTORY = "C:/news";
const getHtml = async (url) => {
try {
const html = await axios.get(url);
return html.data; // HTML 내용을 반환
} catch (error) {
console.error(error);
throw new Error('Failed to fetch HTML content');
}
};
const getContent = async (url, idx) => {
console.log(`${idx}번째 기사 처리중. . .`);
try {
const html = await getHtml(url);
const $ = cheerio.load(html);
let content = $('#dic_area').text();
// HTML 태그 제거
content = content.replace(/<[^>]*>?/gm, '');
// 여분의 공백, 탭, 줄바꿈 문자 제거
content = content.replace(/\s+/g, ' ').trim();
//AI를 이용한 가공
content = await run(content);
return `${idx}번째 기사 \n` + content + `\n\n`;
} catch (error) {
console.error(error);
return `${idx}번째 기사를 가져오는 데 실패했습니다.\n\n`;
}
}
const createNewsSummaryFile = async (contentList) => {
try {
const currentDate = new Date();
const year = currentDate.getFullYear();
const month = currentDate.getMonth() + 1;
const day = currentDate.getDate();
const filePath = path.join(NEWS_SUMMARY_DIRECTORY, `뉴스요약_${year}-${month}-${day}.txt`);
const writeStream = fs.createWriteStream(filePath, { encoding: 'utf-8' });
contentList.forEach(content => writeStream.write(content));
writeStream.end();
console.log(`Successfully saved summary to ${filePath}`);
} catch (error) {
console.error('Failed to create news summary file:', error);
}
};
const extractLinks = async () => {
try {
const html = await getHtml(NAVERN_NEWS_URL);
const $ = cheerio.load(html);
return $('.list a').slice(0, 1).map((index, element) => $(element).attr('href')).get();
} catch (error) {
console.error('Failed to extract links:', error);
return [];
}
};
const extractLinksAndSaveToFile = async () => {
try {
const links = await extractLinks();
const contentList = [];
let idx = 1;
for (const link of links) {
contentList.push(await getContent(link, idx++));
}
await createNewsSummaryFile(contentList);
} catch (error) {
console.error('Failed to extract links and save to file:', error);
}
};
const run = async (content) => {
// For text-only input, use the gemini-pro model
const model = genAI.getGenerativeModel({ model: "gemini-pro"});
let prompt = `I'm going to input a news article. I'm planning to summarize it and post it on my blog. First, please come up with a suitable title and add matching one emojis. And remove any text unrelated to the news content.
Please summarize the input. Change the tone to a more friendly one like '했어요' instead of '습니다'. Provide the result in Korean.\n`
prompt += content
const result = await model.generateContent(prompt);
const response = await result.response;
const text = response.text();
return text;
}
extractLinksAndSaveToFile();
동작 확인을 위해 한개의 뉴스 기사를 시도해 보았고 결과는 아래와 같았다.
1번째 기사 **부동산 심각해 중소 증권사 신용도 '부정적'** 📈📉 최근 한국신용평가에서 증권사들의 부동산 투자에 대한 스트레스 테스트를 진행했어요. 그 결과 중소형 증권사가 여전히 많은 손실 위험을 안고 있어 신용도가 '부정적'이라고 나왔어요. 증권사들은 부동산에 많이 투자했는데, 이 중에서 위험도가 높은 '브릿지론'이 4조8000억원이나 되었어요. 이 브릿지론은 주로 지방의 아파트와 주거용 이외 건물에 대한 대출인데, 중소형 증권사가 잡은 비중이 아주 높아요. 자기자본 대비 부담은 대형 증권사의 두 배 이상이랍니다. 지난해 레고랜드 사태 때 중소형 증권사가 채무 보증을 제공한 경우, 유동화증권의 신용도가 낮은 데다 사업장 위험 부담이 커졌어요. 그래서 중소형 증권사가 보유한 채무 보증 익스포저는 유동성 위기에 주목해야 해요. 향후로는 부실한 부동산 사업장을 정리하면 증권사들의 재무 건전성에 영향을 미칠 것으로 예상돼요. 충당금을 많이 쌓아야 하고 기초 자산을 매입해야 하기 때문에 수익성과 자본 적정성, 유동성이 나빠질 수 있어요. 중소형 증권사는 노멀 시나리오에서도 보유한 브릿지론의 손실률이 44%나 돼요. 이는 충당금이 적당한 수준이라고 가정할 때, 대형 증권사의 충당 수준이 노멀 시나리오 충당금의 90%에 가까운 반면, 중소형 증권사는 69% 수준에 그쳐요. 즉, 중소형 증권사는 여전히 부동산으로 인한 손실 부담에 상당히 노출돼 있답니다. 게다가 중소형 증권사는 해외 상업용 부동산에도 많은 투자를 하고 있는데, 여기서도 위험 요인이 있어요. 일부 회사는 해외 상업용 부동산에 대한 익스포저를 자본력에 비해 과도하게 보유하고 있는데, 아직까지도 충분한 손실을 인식하지 못하고 있어요. 그래서 한국신용평가에서는 중소형 증권사의 신용도 전망을 여전히 '부정적'으로 평가했어요. 부동산 시장이 안정화되더라도 손실 부담은 남아 있을 것이고, 이는 중소형 증권사에 집중돼 있기 때문이에요. |
꽤 만족스러운 결과물이 나왔다. 아직 코드가 잘 정리되어 있지는 않지만, 이번구현은 빠른 완성에 초점을 맞추어 서비스를 고객(아내)에게 제공하는게 중요하다.(그래도 상수들은 분리기 필요해보인다)
우선 지금까지의 결과물을 고객에게 전달하고 요청사항을 반영한 추가구현을 이어나가겠다.
'JS' 카테고리의 다른 글
브라우저 간 호환성 문제 (0) | 2024.05.09 |
---|---|
JS Date 객체 + 백준 1340번 (1) | 2023.12.31 |