AI 기반 RSS 피드 수집기 구축

· 29분 읽기

Microsoft MVP이자 기술 애호가로서 저는 Microsoft DevBlog에 게시된 놀라운 콘텐츠의 바다에 끊임없이 빠져들고 있습니다. .NET 발표부터 Visual Studio 업데이트, Azure 혁신부터 의미 체계 커널 심층 분석까지 Microsoft 생태계에는 항상 새롭고 흥미로운 일이 일어나고 있습니다.

문제? 모든 것을 따라잡는 것은 거의 불가능합니다.

최신 공지 사항을 파악하고 이를 내 네트워크와 공유하고 싶었지만 7개의 서로 다른 RSS 피드를 수동으로 확인하고, 기사를 읽고, 흥미로운 소셜 미디어 게시물을 작성하고, 이미 공유한 내용을 추적하는 것이 그 자체로 정규직이 되었습니다. 매일 아침 나는 여러 개의 브라우저 탭을 열고, 수십 개의 기사를 훑어보고, 내가 이미 공유한 기사를 기억하려고 노력한 다음, 내 관심을 끌었던 기사에 대한 게시물을 작성하는 데 소중한 시간을 보냈습니다.

그래서 나는 개발자라면 누구나 할 법한 일을 했습니다. 자동화했습니다.

이 종합 가이드에서는 새 콘텐츠에 대한 여러 Microsoft DevBlogs RSS 피드를 모니터링하고, Azure OpenAI 및 Semantic Kernel을 사용하여 기사를 분석하고 매력적인 게시물을 생성하고, 분석된 각 기사에 대한 자세한 마크다운 문서를 생성하고, 콘텐츠를 검토 및 공유할 수 있도록 Telegram을 통해 알림을 보내고, 중복 게시물을 피하기 위해 모든 것을 추적하고, GitHub Actions를 통해 자동으로 실행하는 AI 기반 RSS 피드 수집기를 구축하는 방법을 안내합니다.

이 솔루션의 모든 측면을 자세히 살펴보겠습니다.

이 프로젝트의 비하인드 스토리

정보 과잉 속에서 살아가기

이 도구를 만들기 전의 전형적인 아침 모습을 그려보겠습니다. 저는 일어나서 커피를 들고 노트북을 열어 Microsoft 개발자 생태계의 새로운 소식을 확인했습니다. 먼저 주요 DevBlogs 사이트로 이동하여 주요 공지 사항이 있는지 확인했습니다. 그런 다음 .NET 블로그가 나의 주요 기술 스택이기 때문에 구체적으로 확인하겠습니다. 그 후에는 AI가 점점 더 중요해지고 있기 때문에 Semantic Kernel 블로그를 방문하겠습니다. IDE 업데이트가 일상적인 워크플로에 큰 영향을 미칠 수 있기 때문에 Visual Studio 블로그가 그 다음 목록에 포함되었습니다. 그런 다음 CI/CD 및 GitHub 관련 뉴스를 위한 DevOps 블로그가 나왔고, 클라우드 인프라 업데이트를 위한 All Things Azure 블로그, 마지막으로 데이터베이스 혁신을 위한 Azure SQL 블로그가 나왔습니다.

확인해야 할 7가지 피드가 있습니다. 이러한 각 블로그는 매주 여러 개의 기사를 게시하며 때로는 .NET Conf 또는 Build와 같은 주요 발표 기간 동안 하루에 여러 개를 게시합니다. 추적하고 읽고 공유할 수 있는 기사가 수십 개에 달할 수 있습니다. 그리고 여기에 문제가 있습니다. 커뮤니티와의 지식 공유를 중요하게 생각하는 사람으로서 저는 이 기사를 그냥 읽고 싶지 않았습니다. 저는 가장 가치 있는 정보를 LinkedIn의 네트워크와 공유하여 다른 개발자에게도 정보를 제공하고 싶었습니다.하지만 좋은 LinkedIn 게시물을 작성하려면 시간이 걸립니다. 기사를 철저하게 읽고, 핵심 사항을 이해하고, 기사가 청중에게 왜 중요한지 생각하고, 흥미로운 내용을 작성하고, 모든 내용을 멋지게 구성해야 합니다. 여기에 일주일에 여러 기사를 곱하면 작업 시간이 표시됩니다.

내가 정말 원했던 것

몇 달 동안 이 문제를 처리한 후 저는 이상적인 솔루션이 어떤 모습일지 생각해 보았습니다. 무엇보다도 중요한 공지를 다시는 놓치고 싶지 않았습니다. 시스템은 새 기사가 게시되는 즉시 자동으로 이를 포착해야 합니다. 또한 AI가 매력적인 게시물을 작성하는 데 도움을 주어 콘텐츠 제작 시간을 절약하고 싶었습니다. 이는 제 목소리를 완전히 대체하는 것이 아니라 맞춤 설정할 수 있는 확실한 출발점을 제공하기 위함이었습니다.

일관성은 또 다른 큰 요소였습니다. 매일 수동으로 수행할 필요 없이 정기적으로 콘텐츠를 공유하고 싶었습니다. 추적 측면도 매우 중요했습니다. 중복된 게시물을 게시하고 팔로어를 짜증나게 하는 것을 피하기 위해 이미 공유한 내용을 알 수 있는 방법이 필요했습니다. 마지막으로, 내가 처리한 모든 내용을 영구적으로 기록하여 체계적으로 정리하여 내가 다룬 주제가 무엇인지 되돌아보고 확인할 수 있기를 원했습니다.

솔루션의 형태가 바뀌다

제가 구상한 솔루션은 완전히 핸즈프리인 GitHub Actions를 사용하여 일정에 따라 실행됩니다. 단일 브라우저 탭을 열지 않고도 자동으로 7개의 피드를 모두 가져올 수 있습니다. AI 구성 요소는 실제로 콘텐츠를 읽고 이해한 다음 청중에게 유용한 방식으로 요약합니다. 처음부터 게시물을 작성하는 대신, 필요할 경우 수정할 수 있는 즉시 공유 가능한 소셜 미디어 콘텐츠가 생성됩니다. 검토를 위해 모든 것이 내 텔레그램으로 전송되므로 신속하게 휴대폰을 살펴보고 무엇을 공유할지 결정할 수 있었습니다. 그리고 물론, 나중에 참고할 수 있도록 모든 것을 영구적으로 기록해 둘 것입니다.

구축을 시작하기 전에

컴퓨터에 필요한 것

이 튜토리얼을 진행하려면 개발 컴퓨터에 몇 가지 항목을 설치해야 합니다. 가장 중요한 것은 .NET SDK 버전 9.0 이상입니다. 이것은 우리의 런타임이며 필요한 모든 빌드 도구를 제공합니다. 아직 설치되어 있지 않다면 dot.net으로 가서 최신 버전을 다운로드하세요. 설치는 Windows, macOS 또는 Linux에서 간단합니다.

또한 버전 제어를 위해 Git을 설치해야 합니다. 우리는 코드를 GitHub에 푸시하고 자동화를 위해 GitHub Actions를 사용할 것이므로 Git을 로컬로 설정하는 것이 필수적입니다. 최신 버전이면 모두 잘 작동합니다.

개발 환경으로는 Visual Studio 또는 VS Code를 권장합니다. 개인적으로 저는 요즘 대부분의 작업에 VS Code를 사용합니다. VS Code는 가볍고 C# Dev Kit 확장을 통해 뛰어난 C# 지원을 제공하기 때문입니다. 그러나 전체 Visual Studio에 더 익숙하다면 그것도 완벽하게 작동합니다.

필요한 서비스 및 계정로컬 도구 외에도 몇 가지 서비스가 포함된 계정이 필요합니다. 가장 중요한 것은 AI 분석을 지원하는 Azure OpenAI입니다. 이는 종량제 서비스이지만 이 사용 사례에서는 비용이 최소화됩니다. 분석된 기사당 센트를 말하는 것입니다. Azure 계정이 없으면 시작할 수 있는 크레딧이 포함된 무료 평가판에 등록할 수 있습니다.

알림을 위해 Telegram Bot을 사용합니다. Telegram의 가장 큰 장점은 봇 API를 완전히 무료로 사용할 수 있다는 것입니다. 원하는 만큼 봇을 만들고 메시지를 무제한으로 보낼 수 있습니다. 이 가이드 뒷부분에서 설정 과정을 안내해 드리겠습니다.

마지막으로, 코드를 호스팅하고 GitHub Actions를 실행하려면 GitHub 계정이 필요합니다. 이 프로젝트에는 무료 등급이면 충분합니다. GitHub는 개인 저장소에서 매월 2,000분의 Actions 런타임을 제공하고 공용 저장소에서는 무제한(분)을 제공합니다.

이것을 가능하게 하는 라이브러리

우리 프로젝트는 각각 특정 목적을 수행하는 세 가지 주요 NuGet 패키지를 사용합니다.

첫 번째는 .NET의 HTML 구문 분석에 대한 표준인 HtmlAgilityPack입니다. 블로그에서 기사를 가져올 때 탐색 메뉴, 바닥글, 광고 및 관심 없는 모든 종류의 요소를 포함하여 페이지의 전체 HTML을 다시 가져옵니다. HtmlAgilityPack을 사용하면 해당 HTML을 구문 분석하고 필요한 기사 콘텐츠만 추출할 수 있습니다.

두 번째 패키지는 AI 모델을 애플리케이션에 통합하기 위한 Microsoft의 SDK인 Microsoft.SemanticKernel입니다. 이를 .NET 코드와 GPT-4와 같은 대규모 언어 모델 사이의 다리로 생각하십시오. API 호출, 토큰 관리 및 응답 구문 분석의 모든 복잡성을 처리하므로 AI가 실제로 수행하기를 원하는 작업에 집중할 수 있습니다.

세 번째 패키지는 RSS 및 Atom 피드 구문 분석을 기본적으로 지원하는 System.ServiceModel.Syndication입니다. RSS는 오래된 기술처럼 보일 수도 있지만 여전히 블로그와 뉴스 사이트에서 구조화된 업데이트를 얻는 가장 좋은 방법입니다. 이 패키지는 원시 XML 피드를 작업하기 쉬운 강력한 형식의 C# 개체로 변환합니다.

아키텍처 이해

조각이 서로 맞는 방법

코드를 살펴보기 전에 모든 구성 요소가 어떻게 함께 작동하는지 설명하겠습니다. 큰 그림을 이해하면 구현 세부 사항이 훨씬 더 명확해집니다.

최고 수준에는 오케스트레이터 역할을 하는 기본 Program.cs 파일이 있습니다. 이는 애플리케이션의 진입점이며 다른 모든 구성요소를 조정합니다. 애플리케이션이 실행되면 먼저 API 키 및 Telegram 자격 증명과 같은 환경 변수에서 구성을 로드합니다. 그런 다음 나가서 7개의 Microsoft DevBlogs 소스 모두에서 RSS 피드를 가져옵니다. 이러한 피드를 처리하면서 동일한 기사가 여러 피드에 나타나는 경우를 처리하기 위해 기사의 중복을 제거합니다. 추적 파일과 비교하여 각 기사를 확인하여 이미 처리되었는지 확인합니다. 새로운 기사의 경우 처리를 위해 AI 분석기에 전달합니다.ArticleAnalyzer 클래스는 AI 마법이 일어나는 곳입니다. 이 구성 요소는 기사를 수신하고 기사로 여러 가지 작업을 수행합니다. 먼저 기사의 URL에서 전체 HTML 콘텐츠를 가져옵니다. 그런 다음 해당 HTML에서 깨끗한 텍스트를 추출하여 필요하지 않은 모든 탐색 요소, 스크립트 및 스타일을 제거합니다. 깨끗한 텍스트가 있으면 신중하게 제작된 프롬프트와 함께 Semantic Kernel을 통해 이를 Azure OpenAI로 보냅니다. AI는 기사를 분석하고 요약, 주요 주제, 관련성 설명, 가장 중요하게는 즉시 사용 가능한 LinkedIn 게시물을 포함하는 구조화된 응답을 반환합니다. 분석기는 이 응답을 구문 분석하고 이 모든 정보가 포함된 ArticleAnalytic 개체를 반환합니다.

MarkdownGenerator 클래스는 해당 ArticleAnalytic 개체를 가져와 이에 대한 영구 기록을 만듭니다. 모든 기사 메타데이터, AI 분석 및 생성된 게시물을 포함하는 적절한 형식의 마크다운 파일을 생성합니다. 이러한 파일은 생성된 게시물 디렉터리에 저장되어 처리한 모든 내용에 대한 검색 가능한 아카이브를 제공합니다.

마지막으로 Telegram 통합은 생성된 게시물 콘텐츠를 휴대폰으로 보냅니다. 이것은 인간으로서 AI의 작업을 검토하고 공유할지 여부를 결정하는 지점입니다. 봇은 게시물 내용이 포함된 메시지를 보내며, 게시물 내용을 LinkedIn에 직접 복사하거나 먼저 수정할 수 있습니다.

데이터의 흐름

.NET 블로그에 새 기사가 게시되면 어떤 일이 발생하는지 안내해 드리겠습니다. GitHub Actions가 일정에 따라 애플리케이션을 트리거할 때 워크플로가 시작됩니다(예: 6시간마다). 애플리케이션이 깨어나서 7개의 RSS 피드를 모두 가져오기 시작합니다. 각 피드는 해당 블로그의 최신 기사가 포함된 XML 문서를 반환합니다.

각 피드를 구문 분석하면서 개별 기사를 추출하여 목록에 저장합니다. 하지만 여기에 까다로운 부분이 있습니다. 기본 DevBlogs 피드에는 개별 카테고리 피드에도 나타나는 기사가 포함되는 경우가 많습니다. 따라서 “.NET 10"에 대한 기사가 기본 피드와 .NET 관련 피드 모두에 표시될 수 있습니다. 우리는 중복을 자동으로 방지하는 HashSet의 URL을 추적하여 이를 처리합니다.

중복 제거된 기사 목록이 있으면 최근 기사(일반적으로 마지막 날 정도에 게시된 기사)로만 필터링합니다. 이전 실행에서 이미 처리한 오래된 기사를 처리하고 싶지 않습니다. 그런 다음 추적 파일과 비교하여 각 최근 기사를 확인합니다. 기사에 대해 이미 처리하고 게시한 경우 해당 기사를 건너뜁니다.

새로운 기사가 ​​나올 때마다 AI 분석 파이프라인이 시작됩니다. 분석기는 전체 기사 HTML을 가져와 정리한 후 프롬프트와 함께 GPT-4로 보냅니다. AI는 기사를 읽고 LinkedIn 게시물과 함께 포괄적인 분석을 생성합니다. 우리는 기록을 위해 이 분석을 마크다운 파일에 저장합니다.분석이 완료되면 메시지 형식을 지정하고 Telegram을 통해 보냅니다. 메시지에는 URL과 해시태그가 추가된 생성된 게시물 콘텐츠가 포함됩니다. 휴대폰으로 알림을 받고 게시물을 검토한 후 마음에 들면 몇 번의 탭만으로 복사하여 LinkedIn에 공유할 수 있습니다.

마지막으로 추적 파일을 업데이트하여 이 기사를 처리된 것으로 표시하므로 향후 실행에서는 다시 처리하지 않습니다. 파일이 생성되거나 수정된 ​​경우 GitHub Actions는 이러한 변경 사항을 저장소에 다시 커밋하여 모든 것을 동기화합니다.

처음부터 프로젝트 설정하기

솔루션 구조 만들기

건축을 시작합시다. 터미널을 열고 프로젝트를 생성하려는 위치로 이동합니다. 나는 개발 폴더에 프로젝트를 정리하는 것을 좋아하지만, 원하는 곳에 배치할 수 있습니다.

먼저 새 솔루션 파일을 만듭니다. .NET에서 솔루션은 여러 프로젝트를 보유할 수 있는 컨테이너입니다. 지금은 프로젝트가 하나뿐이지만 솔루션부터 시작하면 나중에 필요한 경우 프로젝트를 더 쉽게 추가할 수 있습니다. dotnet new sln -n vs-feed-linkedin 명령을 실행하여 vs-feed-linkedin이라는 솔루션을 생성합니다.

다음으로 콘솔 애플리케이션 프로젝트를 생성해야 합니다. 정리된 상태를 유지하기 위해 이것을 src 하위 디렉터리에 넣을 것입니다. dotnet new console -n VsFeedLinkedin -o src를 실행하여 src 폴더에 VsFeedLinkedin이라는 콘솔 프로젝트를 만듭니다. 그런 다음 dotnet sln add src/VsFeedLinkedin.csproj를 사용하여 이 프로젝트를 솔루션에 추가합니다.

이제 cd src를 사용하여 src 디렉터리로 이동합니다. 여기에서 NuGet 패키지를 추가하고 대부분의 개발을 수행합니다.

필수 패키지 추가

프로젝트가 생성되면 앞서 언급한 세 가지 NuGet 패키지를 추가해야 합니다. 다음 명령을 각각 순서대로 실행하세요.

dotnet add package System.ServiceModel.Syndication --version 9.0.9
dotnet add package Microsoft.SemanticKernel --version 1.30.0
dotnet add package HtmlAgilityPack --version 1.11.72

이 명령을 실행한 후 프로젝트 파일은 다음과 같아야 합니다.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="HtmlAgilityPack" Version="1.11.72" />
    <PackageReference Include="Microsoft.SemanticKernel" Version="1.30.0" />
    <PackageReference Include="System.ServiceModel.Syndication" Version="9.0.9" />
  </ItemGroup>

</Project>

프로젝트 파일은 .NET 9.0을 대상으로 실행 파일(OutputType은 Exe)을 빌드하고 암시적 using 및 null 허용 참조 유형과 같은 최신 C# 기능을 사용하고 있음을 .NET에 알려줍니다. ItemGroup 섹션에는 정확한 버전과 함께 세 가지 패키지 종속성이 나열되어 있습니다.

RSS 피드 심층 분석

RSS란 정확히 무엇인가요?

피드를 가져오는 코드 작성을 시작하기 전에 우리가 작업 중인 내용을 확실히 이해하도록 합시다. RSS는 Really Simple Syndication의 약어이며 콘텐츠 업데이트 배포를 위한 표준화된 XML 형식입니다. 아이디어는 간단합니다. 사용자에게 새 콘텐츠가 있는지 확인하기 위해 웹사이트를 방문하도록 요구하는 대신 최신 콘텐츠를 나열하는 기계 판독 가능 파일을 게시합니다. 그런 다음 애플리케이션은 이 파일을 주기적으로 폴링하여 새 기사를 검색할 수 있습니다.

RSS는 1990년대 후반과 2000년대 초반부터 존재해왔습니다. 오래된 기술이라고 생각할 수도 있지만 실제로는 특히 블로그, 뉴스 사이트, 팟캐스트에서 여전히 널리 사용되고 있습니다. RSS의 장점은 단순성에 있습니다. 이는 정의된 구조를 가진 XML일 뿐이며 모든 애플리케이션에서 이를 구문 분석할 수 있습니다.

DevBlogs 피드의 구조Microsoft DevBlogs에서 RSS 피드를 가져오면 특정 구조를 따르는 XML 문서를 다시 가져옵니다. 최상위 수준에는 단일 채널 요소를 포함하는 RSS 요소가 있습니다. 채널은 블로그 자체를 나타내며 블로그 제목, URL, 설명과 같은 메타데이터를 포함합니다.

채널 내에는 각각 개별 블로그 게시물을 나타내는 여러 항목 요소가 있습니다. 각 항목에는 제목(기사 헤드라인), 링크(전체 기사를 읽을 수 있는 URL), pubDate(기사가 게시된 시기), dc:creator 요소(작성자 이름), 하나 이상의 카테고리 요소(기사 태그) 및 설명(일반적으로 기사 요약 또는 발췌)이 포함됩니다.

다음은 이것이 어떻게 보이는지에 대한 간단한 예입니다.

<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>.NET Blog</title>
    <link>https://devblogs.microsoft.com/dotnet</link>
    <description>The latest news about .NET</description>
    <item>
      <title>Announcing .NET 10</title>
      <link>https://devblogs.microsoft.com/dotnet/announcing-dotnet-10</link>
      <pubDate>Mon, 10 Dec 2025 12:00:00 GMT</pubDate>
      <dc:creator>Microsoft</dc:creator>
      <category>Announcements</category>
      <category>.NET</category>
      <description>Article summary...</description>
    </item>
  </channel>
</rss>

.NET System.ServiceModel.Syndication 패키지의 가장 큰 장점은 이 패키지가 이 모든 것을 구문 분석한다는 것입니다. XML 노드를 수동으로 탐색하거나 다른 RSS 버전에 대해 걱정할 필요가 없습니다. 우리는 피드를 로드하고 강력한 유형의 개체를 다시 가져옵니다.

우리가 모니터링하는 7가지 피드

저는 구현 시 7개의 서로 다른 Microsoft DevBlogs 피드를 모니터링합니다. devblogs.microsoft.com/feed의 기본 DevBlogs 피드에서는 Microsoft가 모든 개발자 블로그에 게시하는 모든 내용을 폭넓게 볼 수 있습니다. devblogs.microsoft.com/dotnet/feed의 .NET 관련 피드는 특히 .NET 릴리스, 기능 및 모범 사례에 중점을 두고 있습니다. devblogs.microsoft.com/semantic-kernel/feed의 Semantic Kernel 피드에서는 AI 오케스트레이션 및 통합을 다룹니다. AI가 현대 개발의 중심이 되면서 점점 더 중요해지고 있습니다.

devblogs.microsoft.com/visualstudio/feed의 Visual Studio 피드에서는 IDE 개선 사항 및 생산성 기능에 대한 최신 정보를 계속해서 받아볼 수 있습니다. devblogs.microsoft.com/devops/feed의 DevOps 피드에서는 Azure DevOps, GitHub 및 CI/CD 주제를 다룹니다. devblogs.microsoft.com/all-things-azure/feed의 All Things Azure 피드는 클라우드 서비스 및 아키텍처 패턴에 중점을 둡니다. 마지막으로 devblogs.microsoft.com/azure-sql/feed의 Azure SQL 피드에서는 데이터베이스 혁신과 기능을 다룹니다.

왜 제가 메인피드와 개별 ​​카테고리 피드를 모두 확인하는지 궁금하실 겁니다. 기본 피드를 통해 폭넓은 정보를 얻을 수 있습니다. 내가 알지 못하는 내용을 포함하여 모든 Microsoft 개발자 블로그의 기사를 볼 수 있습니다. 카테고리 피드는 나에게 깊이를 줍니다. 새로운 콘텐츠로 인해 해당 기사가 기본 피드에서 밀려나더라도 핵심 관심 분야에서 중요한 내용을 놓치지 않도록 해줍니다.

RSS 가져오기 논리 구축

코어 가져오기 기능

이제 몇 가지 코드를 작성해 보겠습니다. 우리 애플리케이션의 기초는 RSS 피드를 가져오고 구문 분석하는 기능입니다. 이를 처리하는 함수는 다음과 같습니다.

static async Task<SyndicationFeed?> FetchRssFeedAsync(string url)
{
    using var httpClient = new HttpClient();
    httpClient.DefaultRequestHeaders.Add("User-Agent", "VsFeedLinkedin/1.0");
    
    var response = await httpClient.GetStringAsync(url);
    
    using var stringReader = new StringReader(response);
    
    var settings = new XmlReaderSettings
    {
        DtdProcessing = DtdProcessing.Parse,
        MaxCharactersFromEntities = 1024
    };
    
    using var xmlReader = XmlReader.Create(stringReader, settings);
    
    return SyndicationFeed.Load(xmlReader);
}

이 코드의 기능을 살펴보겠습니다. HTTP 요청을 만들기 위한 .NET의 기본 제공 클래스인 HttpClient를 만드는 것부터 시작합니다. 일부 서버는 자신을 식별하지 않는 요청을 차단하기 때문에 User-Agent 헤더를 설정합니다. 서버에서 필요하지 않은 경우에도 이를 설정하는 것이 좋습니다.그런 다음 피드 URL에 대해 GET 요청을 보내고 응답을 문자열로 받습니다. 이 문자열에는 RSS 피드의 원시 XML이 포함되어 있습니다.

이 XML을 구문 분석하기 위해 StringReader를 만들어 응답 문자열을 래핑한 다음 일부 XmlReaderSettings를 구성합니다. DtdProcessing 설정은 중요합니다. RSS 피드에는 처리해야 하는 DTD(문서 유형 정의) 선언이 포함되는 경우가 있습니다. MaxCharactersFromEntities 설정은 엔터티 확장이 발생할 수 있는 정도를 제한하여 XML 폭탄 공격을 방지하는 보안 조치입니다.

마지막으로 이러한 설정을 사용하여 XmlReader를 만들고 SyndicationFeed.Load를 사용하여 XML을 강력한 형식의 SyndicationFeed 개체로 구문 분석합니다. 이를 통해 원시 XML 탐색 대신 멋진 C# 속성을 통해 피드의 메타데이터와 모든 항목에 액세스할 수 있습니다.

오류 처리를 통해 여러 피드 가져오기

실제 세계에서는 네트워크 요청이 실패합니다. 서버가 다운되고 연결 시간이 초과되며 XML 형식이 잘못될 수 있습니다. 우리는 이러한 경우를 우아하게 처리해야 합니다. 실패에 대한 복원력을 유지하면서 모든 피드를 가져오는 방법은 다음과 같습니다.

var allArticles = new List<(SyndicationItem item, string feedUrl)>();
var seenUrls = new HashSet<string>();

foreach (var feedUrl in feedUrls)
{
    try
    {
        Console.WriteLine($"  📡 Fetching {feedUrl}...");
        var feed = await FetchRssFeedAsync(feedUrl);
        
        if (feed?.Items != null && feed.Items.Any())
        {
            foreach (var item in feed.Items)
            {
                var itemUrl = item.Links.FirstOrDefault()?.Uri.ToString() ?? "";
                if (!string.IsNullOrEmpty(itemUrl) && seenUrls.Add(itemUrl))
                {
                    allArticles.Add((item, feedUrl));
                }
            }
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine($"  ⚠️  Failed to fetch {feedUrl}: {ex.Message}");
    }
}

우리는 여기서 두 개의 컬렉션을 유지합니다. allArticles 목록에는 우리가 찾은 모든 기사와 해당 기사의 출처가 포함됩니다. visibleUrls HashSet은 우리가 이미 본 기사 URL을 추적하여 중복을 방지하는 데 도움이 됩니다.

각 피드 URL을 반복하고 try-catch 블록에서 가져오기 작업을 래핑합니다. 특정 피드를 가져오는 데 실패하면(서버가 일시적으로 다운되었을 수 있음) 경고를 기록하고 다음 피드를 계속 진행합니다. 이렇게 하면 하나의 피드에 문제가 있어도 다른 피드를 처리하는 데 방해가 되지 않습니다.

성공적으로 가져온 각 피드에 대해 해당 항목을 반복합니다. 항목의 Links 컬렉션에서 기사 URL을 추출합니다. HashSet.Add 메서드는 URL이 이미 세트에 있는 경우 false를 반환하며 이는 중복 제거 논리에 적합합니다. 기사가 새로운 경우에만 목록에 기사를 추가합니다.

이 정보는 나중에 유용할 수 있으므로 각 기사와 함께 피드 URL을 저장합니다. 예를 들어 디버깅 또는 로깅 목적으로 기사가 어떤 특정 피드에서 왔는지 알고 싶을 수 있습니다.

중복 처리 및 상태 추적

중복 제거 과제

앞서 언급했듯이 Microsoft DevBlogs에는 흥미로운 문제를 일으키는 계층적 피드 구조가 있습니다. .NET 팀 구성원이 .NET 10의 성능 향상에 대한 기사를 게시하면 해당 기사가 기본 DevBlogs 피드와 .NET 관련 피드 모두에 나타날 가능성이 높습니다. 때로는 IDE 기능과 관련된 경우 Visual Studio 피드에 나타날 수도 있습니다.

모든 피드의 모든 기사를 순진하게 처리했다면 결국 동일한 기사에 대해 여러 번 분석하고 게시하게 될 것입니다. 그러면 Azure OpenAI에 대한 API 호출이 낭비되고, 텔레그램에 중복 알림이 발송되며, 중복된 알림을 게시하면 팔로어가 잠재적으로 짜증을 낼 수 있습니다.해결책은 URL 기반 중복 제거입니다. 각 기사에는 고유한 URL이 있으므로 이를 식별자로 사용할 수 있습니다. HashSet 데이터 구조는 O(1) 조회 시간을 제공하고 자동으로 중복을 방지하므로 이에 적합합니다. 이미 집합에 있는 URL을 추가하려고 하면 Add 메서드는 단순히 false를 반환하여 해당 항목을 건너뛰어야 한다는 것을 알려줍니다.

마크다운을 사용한 지속 상태

중복 제거는 단일 실행 내에서 중복을 처리하지만 전체 실행에서는 어떻습니까? 애플리케이션이 6시간마다 실행될 때 이미 처리한 기사를 기억하여 다시 처리하지 않도록 해야 합니다.

나는 이 상태를 Posted-articles.md라는 마크다운 파일에 저장하기로 결정했습니다. 왜 마크다운인가? 몇 가지 이유. 첫째, 사람이 읽을 수 있습니다. 파일을 열고 내가 어떤 글을 공유했는지 즉시 확인할 수 있습니다. 둘째, 버전 관리입니다. 이 파일은 Git 저장소에 있으므로 기사가 처리된 시점에 대한 완전한 기록을 가지고 있습니다. 셋째, 문서화 역할을 합니다. 저장소를 보는 사람은 누구나 애플리케이션이 수행한 작업을 볼 수 있습니다.

이 파일의 형식은 간단합니다. 여기에는 헤더, 애플리케이션이 마지막으로 실행된 시간을 보여주는 타임스탬프, 마크다운 링크 형식의 기사 목록이 있습니다.

# Posted Articles

*Last run: 2025-12-10 15:30:00*

List of articles posted to LinkedIn:

- [Announcing .NET 10](https://devblogs.microsoft.com/dotnet/announcing-dotnet-10?wt.mc_id=DT-MVP-5004972) - Posted on 2025-12-10 15:30:00 (Published: 2025-12-10)
- [Visual Studio 2026 Preview](https://devblogs.microsoft.com/visualstudio/vs-2026-preview?wt.mc_id=DT-MVP-5004972) - Posted on 2025-12-09 10:15:00 (Published: 2025-12-09)

추적 파일 로드 및 구문 분석

기사를 이미 처리했는지 확인하려면 이 파일을 로드하고 URL을 추출해야 합니다. 이 작업을 수행하는 함수는 다음과 같습니다.

static HashSet<string> LoadPostedArticles(string filePath)
{
    var postedUrls = new HashSet<string>();
    
    if (!File.Exists(filePath))
    {
        return postedUrls;
    }

    var lines = File.ReadAllLines(filePath);
    foreach (var line in lines)
    {
        var match = System.Text.RegularExpressions.Regex.Match(line, @"\(([^)]+)\)");
        if (match.Success)
        {
            var url = match.Groups[1].Value;
            
            if (url.Contains("?wt.mc_id="))
            {
                url = url.Substring(0, url.IndexOf("?wt.mc_id="));
            }
            else if (url.Contains("?"))
            {
                url = url.Substring(0, url.IndexOf("?"));
            }
            
            url = url.TrimEnd('/');
            postedUrls.Add(url);
        }
    }

    return postedUrls;
}

이 함수는 우리가 이미 처리한 모든 URL을 포함하는 HashSet을 반환합니다. 파일이 존재하는지 확인하는 것부터 시작합니다. 처음 실행하면 존재하지 않으므로 빈 세트를 반환합니다.

파일의 각 줄에 대해 정규식을 사용하여 마크다운 링크 형식에서 URL을 추출합니다. 정규식 \(([^)]+)\)은 마크다운 링크가 URL을 저장하는 괄호 안의 모든 항목과 일치합니다.

그런 다음 중요한 단계인 URL 정규화를 수행합니다. 동일한 기사에 대한 URL의 형식은 다를 수 있습니다. RSS 피드는 https://devblogs.microsoft.com/dotnet/article를 제공할 수 있지만 저장된 버전에는 추적 매개변수 https://devblogs.microsoft.com/dotnet/article?wt.mc_id=DT-MVP-5004972가 추가되어 있습니다. 일부 URL에는 뒤에 슬래시가 있지만 다른 URL에는 없습니다.

이를 처리하기 위해 모든 쿼리 매개변수(? 뒤의 모든 항목)를 제거하고 후행 슬래시를 제거합니다. 이러한 정규화를 통해 기사의 URL이 표면적으로 다르더라도 기사를 중복으로 인식할 수 있습니다.

새 기사 저장

기사를 성공적으로 처리하면 이를 추적 파일에 추가해야 합니다.

static void SavePostedArticle(string filePath, string url, string title, DateTimeOffset publishDate)
{
    var markdownEntry = $"- [{title}]({url}) - Posted on {DateTime.Now:yyyy-MM-dd HH:mm:ss} (Published: {publishDate:yyyy-MM-dd})\n";
    
    if (!File.Exists(filePath))
    {
        File.WriteAllText(filePath, "# Posted Articles\n\n*Last run: {DateTime.Now:yyyy-MM-dd HH:mm:ss}*\n\nList of articles posted:\n\n");
    }
    
    File.AppendAllText(filePath, markdownEntry);
}

이 기능은 기사 제목을 링크로 사용하고 그 뒤에 기사를 게시한 시기와 원래 게시된 시기를 보여주는 타임스탬프가 포함된 마크다운 형식의 항목을 생성합니다. 파일이 아직 존재하지 않으면 먼저 헤더를 사용하여 파일을 만듭니다.

AI 분석 엔진

시맨틱 커널 이해하기이제 우리 애플리케이션의 가장 흥미로운 부분인 AI 분석에 들어갑니다. Semantic Kernel은 대규모 언어 모델을 애플리케이션에 통합하기 위한 Microsoft의 오픈 소스 SDK입니다. 이는 API 호출을 둘러싼 래퍼 그 이상입니다. 플러그인, 플래너, 메모리와 같은 기능을 갖춘 정교한 AI 애플리케이션을 구축하기 위한 프레임워크를 제공합니다.

사용 사례에서는 Semantic Kernel의 채팅 완료 기능을 사용하고 있습니다. Azure OpenAI에 메시지를 보내면 모델이 기사를 분석하고 응답을 생성합니다. 시맨틱 커널은 API 인증, 요청 형식화 및 응답 구문 분석의 모든 복잡성을 처리합니다.

기사 분석기 설정

분석기 클래스를 설정하는 방법을 살펴보겠습니다.

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using HtmlAgilityPack;

namespace VsFeedLinkedin.Services;

public class ArticleAnalyzer
{
    private readonly Kernel _kernel;
    private readonly IChatCompletionService _chatService;

    public ArticleAnalyzer(string endpoint, string apiKey, string deploymentName)
    {
        var builder = Kernel.CreateBuilder();
        
        builder.AddAzureOpenAIChatCompletion(
            deploymentName: deploymentName,
            endpoint: endpoint,
            apiKey: apiKey
        );
        
        _kernel = builder.Build();
        _chatService = _kernel.GetRequiredService<IChatCompletionService>();
    }

시맨틱 커널은 구성을 위해 빌더 패턴을 사용합니다. KernelBuilder를 만들고 필요한 자격 증명을 사용하여 Azure OpenAI 채팅 완료 서비스를 추가한 다음 커널을 빌드합니다. 빌드된 커널에서 프롬프트를 보내고 응답을 받는 데 사용할 IChatCompletionService 인터페이스를 검색합니다.

생성자는 Azure OpenAI 엔드포인트(예: https://your-resource.openai.azure.com/), API 키 및 배포 이름(예: gpt-4o)의 세 가지 매개 변수를 사용합니다. 이는 환경 변수에서 전달되어 자격 증명을 안전하게 유지합니다.

완벽한 프롬프트 만들기

우리가 AI에 보내는 메시지는 매우 중요합니다. 잘 만들어진 프롬프트는 일관된 고품질 출력을 생성합니다. 모호하거나 잘못 구성된 프롬프트는 일관성이 없고 평범한 결과를 낳습니다. 나는 만족스러운 결과를 얻기 위해 이 프롬프트를 반복하는 데 상당한 시간을 보냈습니다.

var prompt = $"""
    You are a professional tech content analyst and LinkedIn content creator. 
    Analyze the following Microsoft DevBlogs article and create an engaging LinkedIn post.

    Article Title: {title}
    Author: {author}
    URL: {url}
    Tags: {string.Join(", ", tags)}
    
    Article Content:
    {cleanContent}

    Please provide:
    1. A brief summary (2-3 sentences) of the key points
    2. The main technologies or topics covered
    3. Why this is relevant for developers/tech professionals
    4. An engaging LinkedIn post (max 1300 characters) that:
       - Starts with a hook or attention-grabbing statement
       - Highlights the key value for readers
       - Includes a call to action
       - Uses appropriate emojis (but not too many)
       - Maintains a professional yet approachable tone
       - DO NOT include hashtags in the post (they will be added separately)
       - DO NOT include the URL in the post (it will be added separately)

    Format your response as follows:
    ## Summary
    [Your summary here]

    ## Key Topics
    [List of main topics/technologies]

    ## Relevance
    [Why this matters]

    ## LinkedIn Post
    [Your engaging LinkedIn post here]
    """;

여기서 디자인 결정을 설명하겠습니다. 우리는 AI에게 “당신은 전문 기술 콘텐츠 분석가이자 LinkedIn 콘텐츠 제작자입니다.“라는 명확한 역할을 부여하는 것부터 시작합니다. 이는 모델이 적절한 스타일과 목소리로 반응하도록 준비시킵니다.

기사 제목, 작성자, URL, RSS 피드의 태그, 전체 기사 콘텐츠 등 AI에 필요한 모든 컨텍스트를 제공합니다. 더 많은 맥락을 제공할수록 분석이 더 좋아질 것입니다.

그런 다음 우리가 원하는 것을 정확히 지정합니다. 요약, 핵심 주제, 관련성 설명, LinkedIn 게시물의 네 가지를 요청합니다. 특히 LinkedIn 게시물의 경우 좋은 게시물을 만드는 방법에 대한 자세한 지침을 제공합니다. 즉, 관심을 끌 수 있어야 하고, 가치를 강조해야 하며, 클릭 유도 문구를 포함하고, 이모티콘을 적절하게 사용하고, 전문적인 분위기를 유지해야 합니다.

부정적인 지시도 마찬가지로 중요합니다. 나는 AI에게 게시물에 해시태그나 URL을 포함하지 말라고 명시적으로 지시합니다. 왜? 왜냐하면 저는 이것을 별도로 추가하고 AI가 이를 포함했다면 중복된 내용을 갖게 되기 때문입니다. 이런 종류의 명시적인 지시는 흔히 발생하는 실수를 방지합니다.

마지막으로 정확한 출력 형식을 지정합니다. ## 헤더로 표시된 섹션을 요청함으로써 프로그래밍 방식으로 응답을 쉽게 구문 분석할 수 있습니다. AI는 형식 지정 지침을 매우 잘 따르며 이러한 일관성으로 인해 구문 분석 코드가 더 간단하고 안정적이게 됩니다.

분석 실행

모든 것을 종합하는 방법은 다음과 같습니다.

public async Task<ArticleAnalysis> AnalyzeArticleAsync(
    string title, 
    string url, 
    string htmlContent, 
    string author, 
    List<string> tags)
{
    var cleanContent = ExtractTextFromHtml(htmlContent);
    
    if (cleanContent.Length > 8000)
    {
        cleanContent = cleanContent.Substring(0, 8000) + "...";
    }

    var chatHistory = new ChatHistory();
    chatHistory.AddUserMessage(prompt);

    var response = await _chatService.GetChatMessageContentAsync(chatHistory);
    var responseText = response.Content ?? "";

    return ParseAnalysisResponse(responseText, title, url, author, tags);
}
```먼저 HTML 콘텐츠에서 깨끗한 텍스트를 추출합니다(다음 섹션에서 이에 대해 설명하겠습니다). 그런 다음 내용이 너무 길면 내용을 자릅니다. 대규모 언어 모델에는 토큰 제한이 있으며 매우  기사가 이를 초과할  있습니다. 8,000자로 제한함으로써 상당한 맥락을 제공하면서도 한도 내에서 유지되도록 보장합니다.

ChatHistory 개체를 만들고 프롬프트를 사용자 메시지로 추가합니다. 이는 채팅 기반 상호작용을 위한 Semantic Kernel의 추상화입니다. 이를 채팅 완료 서비스로 보내고 응답을 받습니다. 마지막으로 응답을 구문 분석하여 개별 섹션을 추출합니다.

### AI 응답 구문 분석

AI는 요청한 구조에 따라 형식이 지정된 텍스트로 응답을 반환합니다. 이를 개별 필드로 구문 분석해야 합니다.

```csharp
private static ArticleAnalysis ParseAnalysisResponse(
    string response, 
    string title, 
    string url, 
    string author, 
    List<string> tags)
{
    var analysis = new ArticleAnalysis
    {
        Title = title,
        Url = url,
        Author = author,
        Tags = tags,
        RawAnalysis = response
    };

    var sections = response.Split("##", StringSplitOptions.RemoveEmptyEntries);
    
    foreach (var section in sections)
    {
        var lines = section.Trim().Split('\n', 2);
        if (lines.Length < 2) continue;
        
        var sectionTitle = lines[0].Trim().ToLower();
        var sectionContent = lines[1].Trim();

        switch (sectionTitle)
        {
            case "summary":
                analysis.Summary = sectionContent;
                break;
            case "key topics":
                analysis.KeyTopics = sectionContent;
                break;
            case "relevance":
                analysis.Relevance = sectionContent;
                break;
            case "linkedin post":
                analysis.LinkedInPost = sectionContent;
                break;
        }
    }

    return analysis;
}

응답을 ## 마커로 분할하여 각 섹션을 제공합니다. 각 섹션에 대해 헤더와 콘텐츠를 분리하기 위해 줄바꿈으로 분할했습니다. 그런 다음 switch 문을 사용하여 각 섹션의 콘텐츠를 적절한 속성에 할당합니다.

또한 구문 분석되지 않은 원시 응답도 저장합니다. 이는 디버깅에 유용합니다. 구문 분석에 문제가 있는 경우 AI가 실제로 반환한 내용을 확인할 수 있습니다.

HTML에서 콘텐츠 추출

HTML을 정리해야 하는 이유

블로그에서 기사를 가져올 때 페이지의 전체 HTML을 얻습니다. 여기에는 기사 콘텐츠 그 이상을 포함합니다. 탐색 메뉴, 머리글, 바닥글, 사이드바, 관련 기사 위젯, 댓글 섹션, 분석 및 추적용 스크립트, 스타일시트 및 모든 종류의 기타 요소가 포함됩니다.

이 모든 것을 AI에 보내면 몇 가지 나쁜 일이 일어날 것입니다. AI는 관련 없는 텍스트를 많이 처리해야 하고 토큰을 낭비하며 잠재적으로 분석을 혼란스럽게 해야 합니다. 탐색 및 바닥글 텍스트가 요약에 포함될 수 있습니다. 스크립트와 CSS는 콘텐츠로 취급되어 분석을 더욱 오염시킵니다.

우리는 인간 독자가 실제로 읽을 부분인 기사 내용만 추출해야 합니다.

HtmlAgilityPack 사용

HtmlAgilityPack은 .NET용 강력한 HTML 구문 분석 라이브러리입니다. XML과 달리 HTML은 형식이 잘못된 경우가 많습니다. 태그가 제대로 닫히지 않거나 속성이 올바르게 인용되지 않을 수 있습니다. HtmlAgilityPack은 이 모든 것을 우아하게 처리하여 쿼리하고 조작할 수 있는 DOM과 같은 구조를 제공합니다.

추출 기능은 다음과 같습니다.

private static string ExtractTextFromHtml(string html)
{
    if (string.IsNullOrWhiteSpace(html))
        return string.Empty;

    var doc = new HtmlDocument();
    doc.LoadHtml(html);

    var nodesToRemove = doc.DocumentNode.SelectNodes("//script|//style|//nav|//footer|//header");
    if (nodesToRemove != null)
    {
        foreach (var node in nodesToRemove)
        {
            node.Remove();
        }
    }

    var text = doc.DocumentNode.InnerText;
    
    text = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ");
    return text.Trim();
}

HTML을 HtmlDocument에 로드하여 트리 구조로 구문 분석합니다. 그런 다음 XPath를 사용하여 제거하려는 모든 노드를 선택합니다. XPath 표현식 //script|//style|//nav|//footer|//header는 모든 스크립트 요소(필요하지 않은 JavaScript 코드), 스타일 요소(필요하지 않은 CSS), nav 요소(탐색 메뉴), 바닥글 요소 및 헤더 요소를 선택합니다.

이러한 노드를 제거한 후 HTML 태그를 제거하는 동안 모든 텍스트 내용을 추출하는 InnerText 속성을 얻습니다. 이는 기사의 일반 텍스트를 제공합니다.마지막으로 공백을 정리합니다. HTML에는 서식 지정을 위해 공백, 탭, 줄 바꿈 등 많은 추가 공백이 있는 경우가 많습니다. 정규식을 사용하여 일련의 공백 문자를 단일 공백으로 바꾼 다음 결과를 자릅니다.

전체 기사 가져오기

RSS 피드는 전체 기사 내용이 아닌 요약만 제공합니다. 전체 텍스트를 얻으려면 기사의 웹 페이지를 가져와야 합니다.

public static async Task<string> FetchArticleContentAsync(string url)
{
    using var httpClient = new HttpClient();
    httpClient.DefaultRequestHeaders.Add("User-Agent", "VsFeedLinkedin/1.0");
    
    try
    {
        return await httpClient.GetStringAsync(url);
    }
    catch (Exception ex)
    {
        Console.WriteLine($"⚠️ Failed to fetch article content: {ex.Message}");
        return string.Empty;
    }
}

이는 간단합니다. 기사 URL에 대해 HTTP GET 요청을 하고 HTML 응답을 반환합니다. 네트워크 요청이 실패할 수 있으므로 try-catch로 래핑하고 전체 애플리케이션을 충돌시키는 것보다 빈 문자열을 반환하는 것이 좋습니다.

영구 문서 만들기

마크다운 파일을 생성하는 이유

기사를 분석할 때마다 해당 분석을 문서화하는 상세한 마크다운 파일을 생성합니다. 이는 여러 가지 목적으로 사용됩니다.

먼저 검색 가능한 아카이브를 생성합니다. 시간이 지남에 따라 분석된 기사 모음을 구축하게 됩니다. 이러한 파일을 검색하여 특정 주제에 대한 과거 콘텐츠를 찾을 수 있습니다.

둘째, 투명성을 제공합니다. 전체 분석 및 LinkedIn 게시물을 포함하여 각 기사에 대해 AI가 생성한 내용을 정확하게 확인할 수 있습니다.

셋째, 디버깅에 유용합니다. 게시물에 문제가 있는 경우 마크다운 파일을 보고 무슨 일이 일어났는지 이해할 수 있습니다.

마크다운 생성기 클래스

public class MarkdownGenerator
{
    private readonly string _outputDirectory;

    public MarkdownGenerator(string outputDirectory)
    {
        _outputDirectory = outputDirectory;
        
        if (!Directory.Exists(_outputDirectory))
        {
            Directory.CreateDirectory(_outputDirectory);
        }
    }

    public string GenerateMarkdownFile(ArticleAnalysis analysis)
    {
        var sb = new StringBuilder();
        
        var safeTitle = GenerateSafeFileName(analysis.Title);
        var fileName = $"{analysis.AnalyzedAt:yyyy-MM-dd}_{safeTitle}.md";
        var filePath = Path.Combine(_outputDirectory, fileName);

        sb.AppendLine($"# {analysis.Title}");
        sb.AppendLine();
        sb.AppendLine("## Article Information");
        sb.AppendLine();
        sb.AppendLine($"- **Author:** {analysis.Author}");
        sb.AppendLine($"- **URL:** [{analysis.Url}]({analysis.Url})");
        sb.AppendLine($"- **Published:** {analysis.PublishDate:yyyy-MM-dd}");
        sb.AppendLine($"- **Analyzed:** {analysis.AnalyzedAt:yyyy-MM-dd HH:mm:ss}");
        sb.AppendLine($"- **Tags:** {string.Join(", ", analysis.Tags)}");
        sb.AppendLine();
        sb.AppendLine("");
        sb.AppendLine();
        sb.AppendLine("## AI Analysis");
        sb.AppendLine();
        sb.AppendLine("### Summary");
        sb.AppendLine();
        sb.AppendLine(analysis.Summary);
        sb.AppendLine();
        sb.AppendLine("### Key Topics");
        sb.AppendLine();
        sb.AppendLine(analysis.KeyTopics);
        sb.AppendLine();
        sb.AppendLine("### Relevance for Developers");
        sb.AppendLine();
        sb.AppendLine(analysis.Relevance);
        sb.AppendLine();
        sb.AppendLine("");
        sb.AppendLine();
        sb.AppendLine("## Generated LinkedIn Post");
        sb.AppendLine();
        sb.AppendLine("```");
        sb.AppendLine(분석.LinkedInPost);
        sb.AppendLine("```");
        sb.AppendLine();
        sb.AppendLine("");
        sb.AppendLine();
        sb.AppendLine("*This analysis was generated using AI (Semantic Kernel with Azure OpenAI)*");

        File.WriteAllText(filePath, sb.ToString());
        return filePath;
    }

생성자는 출력 디렉터리 경로를 가져와서 존재하지 않는 경우 생성합니다. generateMarkdownFile 메서드는 ArticleAnalytic 개체를 사용하여 적절한 형식의 마크다운 문서를 생성합니다.

파일 이름에는 날짜와 제목의 삭제된 버전이 포함됩니다. 이를 통해 파일을 시간순으로 쉽게 정렬하고 한눈에 식별할 수 있습니다.

안전하지 않은 파일 이름 처리

기사 제목에는 파일 이름에 허용되지 않는 문자(콜론, 슬래시, 물음표, 따옴표 등)가 포함될 수 있습니다. 우리는 다음을 소독해야 합니다:

private static string GenerateSafeFileName(string title)
{
    var invalidChars = Path.GetInvalidFileNameChars();
    var safeTitle = new string(title
        .Where(c => !invalidChars.Contains(c))
        .ToArray());
    
    safeTitle = safeTitle.Replace(" ", "-").Replace("--", "-");
    
    if (safeTitle.Length > 50)
    {
        safeTitle = safeTitle.Substring(0, 50);
    }
    
    return safeTitle.TrimEnd('-').ToLowerInvariant();
}

Path.GetInvalidFileNameChars()를 사용하여 현재 운영 체제에서 파일 이름에 나타날 수 없는 문자 목록을 가져옵니다. 이를 필터링하고, 가독성을 위해 공백을 하이픈으로 바꾸고, 길이를 50자로 제한하고, 일관성을 위해 소문자로 변환합니다.

텔레그램 알림 설정

내가 텔레그램을 선택한 이유

알림 구성 요소의 경우 이메일, SMS, Slack, Discord 및 Telegram과 같은 여러 옵션을 고려했습니다. 저는 여러 가지 이유로 궁극적으로 Telegram을 선택했습니다.

API는 합리적인 사용을 위해 속도 제한 없이 완전 무료입니다. 많은 알림 서비스에는 무료로 보낼 수 있는 메시지 수에 제한이 있지만 Telegram은 봇 메시지를 개별 사용자에게 제한하지 않습니다.

봇 API는 놀라울 정도로 간단합니다. JSON 페이로드가 포함된 HTTP 요청일 뿐입니다. 복잡한 인증 흐름이 없으며 기본 기능에 웹후크가 필요하지 않습니다.텔레그램은 휴대폰, 데스크톱, 웹 브라우저 등 어디에서나 작동합니다. 내가 어디에 있든 알림을 받고 즉시 응답할 수 있습니다.

메시지는 다양한 형식을 지원합니다. 굵은 텍스트, 기울임꼴, 심지어 코드 블록을 사용하여 알림을 더 읽기 쉽게 만들 수 있습니다.

텔레그램 봇 만들기

텔레그램 봇을 설정하는 것은 놀라울 정도로 쉽습니다. Telegram을 열고 @BotFather를 검색하세요. 이는 봇 생성 및 관리를 위한 Telegram의 공식 봇입니다. BotFather와 대화를 시작하고 /newbot 명령을 보냅니다. BotFather는 봇의 이름(표시 이름)과 사용자 이름(고유해야 하며 “bot"으로 끝나야 함)을 묻습니다. 이를 제공하면 BotFather가 봇을 생성하고 API 토큰을 제공합니다. 이 토큰은 비밀번호와 같습니다. 비밀로 유지하고 공개 저장소에 커밋하지 마세요.

봇이 메시지를 보낼 위치를 알 수 있도록 채팅 ID를 찾으려면 검색하고 시작을 눌러 새 봇과 대화를 시작하세요. 그런 다음 브라우저에서 또는 컬을 사용하여 URL https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates에 액세스합니다. 응답에서 chat 개체를 찾으세요. id 필드는 채팅 ID입니다.

API를 통해 메시지 보내기

텔레그램 메시지를 보내는 기능은 다음과 같습니다.

static async Task SendToTelegramAsync(string botToken, string chatId, string message)
{
    using var httpClient = new HttpClient();
    
    var telegramApiUrl = $"https://api.telegram.org/bot{botToken}/sendMessage";
    
    var payload = new
    {
        chat_id = chatId,
        text = message,
        parse_mode = "HTML"
    };
    
    var jsonContent = JsonSerializer.Serialize(payload);
    var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
    
    var response = await httpClient.PostAsync(telegramApiUrl, content);
    
    if (!response.IsSuccessStatusCode)
    {
        var errorContent = await response.Content.ReadAsStringAsync();
        throw new Exception($"Telegram API error: {response.StatusCode} - {errorContent}");
    }
}

Telegram Bot API는 REST 기반입니다. 채팅 ID(보내는 위치), 메시지 텍스트(보내는 내용) 및 선택적으로 구문 분석 모드(서식 지정용)가 포함된 JSON 본문을 사용하여 sendMessage 엔드포인트에 대한 POST 요청을 수행합니다.

parse_mode를 “HTML"로 설정하면 메시지에 <b>bold</b><i>italic</i>와 같은 기본 HTML 태그를 사용할 수 있습니다. 이렇게 하면 알림을 더 읽기 쉽게 만들 수 있지만 현재 사용 사례에서는 일반 텍스트를 보냅니다.

요청이 실패하면 무엇이 잘못되었는지에 대한 세부정보와 함께 예외가 발생합니다. 이는 무언가가 작동하지 않는 경우 디버깅에 도움이 됩니다.

애플리케이션 구성

환경 변수

우리 애플리케이션에는 API 키, 봇 토큰, 엔드포인트 URL 등 여러 가지 민감한 정보가 필요합니다. 우리는 이것을 하드코딩하거나 버전 관리에 커밋해서는 안 됩니다. 대신 애플리케이션이 실행되는 각 환경에서 안전하게 설정할 수 있는 환경 변수를 사용합니다.

텔레그램의 경우 TELEGRAM_BOT_TOKEN(BotFather가 제공한 토큰)과 TELEGRAM_CHAT_ID(메시지를 보내야 하는 채팅 ID)가 필요합니다.

Azure OpenAI의 경우 AZURE_OPENAI_ENDPOINT(리소스 URL), AZURE_OPENAI_API_KEY(API 키) 및 AZURE_OPENAI_DEPLOYMENT(“gpt-4o"와 같은 배포된 모델 이름)가 필요합니다.

코드에서 구성 로드

애플리케이션 시작 시 이러한 값을 로드하는 방법은 다음과 같습니다.

var telegramBotToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT_TOKEN");
var telegramChatId = Environment.GetEnvironmentVariable("TELEGRAM_CHAT_ID");

var azureOpenAiEndpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT");
var azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY");
var azureOpenAiDeployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "gpt-4o";

var aiAnalysisEnabled = !string.IsNullOrWhiteSpace(azureOpenAiEndpoint) && 
                        !string.IsNullOrWhiteSpace(azureOpenAiKey);

Environment.GetEnvironmentVariable을 사용하여 각 값을 읽습니다. 배포 이름에는 값이 설정되지 않은 경우 기본값인 “gpt-4o"가 제공됩니다.

그런 다음 엔드포인트와 API 키가 모두 있는지 확인하여 AI 분석을 활성화해야 하는지 여부를 확인합니다. 이를 통해 Azure OpenAI가 구성되지 않은 경우 애플리케이션이 저하된 모드에서 실행될 수 있습니다. AI 분석 없이도 여전히 피드를 가져오고 기사를 추적합니다.### 우아한 저하

이러한 우아한 저하 개념은 중요합니다. 하나의 선택적 기능이 구성되지 않았기 때문에 애플리케이션이 충돌하는 것을 원하지 않습니다.

ArticleAnalyzer? articleAnalyzer = null;
MarkdownGenerator? markdownGenerator = null;

if (aiAnalysisEnabled)
{
    Console.WriteLine("🤖 AI Analysis enabled - Using Azure OpenAI with Semantic Kernel");
    articleAnalyzer = new ArticleAnalyzer(azureOpenAiEndpoint!, azureOpenAiKey!, azureOpenAiDeployment);
    markdownGenerator = new MarkdownGenerator(articlesOutputDir);
}
else
{
    Console.WriteLine("ℹ️  AI Analysis disabled - Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY to enable");
}

AI가 활성화되면 분석기와 마크다운 생성기를 만듭니다. 그렇지 않은 경우 null로 두고 처리 중에 AI 관련 단계를 건너뜁니다. AI 향상 없이도 애플리케이션은 피드를 가져오고 기본 알림을 보내 여전히 가치를 제공합니다.

GitHub Actions로 자동화

GitHub Action을 사용해야 하는 이유

이 솔루션의 진정한 힘은 자동화에서 나옵니다. 우리는 애플리케이션을 몇 시간마다 수동으로 실행하고 싶지 않고 백그라운드에서 자동으로 실행되기를 원합니다.

GitHub Actions가 이에 적합합니다. GitHub에 내장되어 있으므로 추가로 설정할 서비스가 없습니다. 공용 저장소의 경우 무료이며 개인 저장소의 경우 넉넉한 무료 시간이 포함되어 있습니다. 일정에 따라 실행되어 정기적으로 애플리케이션을 트리거할 수 있습니다. API 키를 안전하게 저장하기 위한 비밀 관리 기능이 내장되어 있습니다. 그리고 변경 사항을 저장소에 다시 커밋하여 추적 파일을 최신 상태로 유지할 수 있습니다.

워크플로 파일

.github/workflows/fetch-and-notify.yml에 다음 내용이 포함된 파일을 만듭니다.

name: Fetch DevBlogs and Notify

on:
  schedule:
    - cron: '0 */6 * * *'
  workflow_dispatch:

jobs:
  fetch-and-notify:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      
      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.0.x'
      
      - name: Restore dependencies
        run: dotnet restore src/VsFeedLinkedin.csproj
      
      - name: Build
        run: dotnet build src/VsFeedLinkedin.csproj --no-restore
      
      - name: Run application
        env:
          TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
          TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
          AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
          AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
          AZURE_OPENAI_DEPLOYMENT: ${{ secrets.AZURE_OPENAI_DEPLOYMENT }}
        run: dotnet run --project src/VsFeedLinkedin.csproj
      
      - name: Commit and push changes
        run: |
          git config user.name "GitHub Actions Bot"
          git config user.email "[email protected]"
          
          if [[ -n $(git status --porcelain posted-articles.md generated-posts/) ]]; then
            TIMESTAMP=$(date +%Y%m%d_%H%M%S)
            git add posted-articles.md generated-posts/
            git commit -m "chore($TIMESTAMP): processed new articles"
            git push
          else
            echo "No changes to commit"
          fi

각 부분을 설명하겠습니다. on 섹션은 워크플로가 실행되는 시기를 정의합니다. 일정 트리거는 cron 구문을 사용합니다. 0 */6 * * *는 “매 6시간의 0분에"를 의미합니다. 따라서 워크플로는 UTC 기준 자정, 오전 6시, 정오 및 오후 6시에 실행됩니다. Workflow_dispatch 트리거를 사용하면 GitHub UI에서 수동으로 실행할 수 있어 테스트에 유용합니다.

작업은 Linux 가상 머신인 ubuntu-latest에서 실행됩니다. 저장소를 확인하고, .NET 9를 설정하고, NuGet 패키지를 복원하고, 프로젝트를 빌드합니다.

애플리케이션 실행 단계에서는 마법 같은 일이 일어납니다. ${{ secrets.SECRET_NAME }} 구문을 사용하여 비밀을 환경 변수로 전달합니다. 이러한 비밀은 GitHub에 안전하게 저장되며 로그에 노출되지 않습니다.

마지막으로 모든 변경 사항을 저장소에 다시 커밋합니다. 봇 ID로 Git을 구성하고, 추적 파일이나 생성된 게시물 디렉터리에 변경 사항이 있는지 확인하고, 그렇다면 커밋을 생성하여 푸시합니다.

비밀 설정

GitHub 저장소에 비밀을 추가하려면 저장소의 설정, 비밀 및 변수, 작업으로 이동하세요. “새 저장소 비밀"을 클릭하고 각 환경 변수를 추가하십시오. 이름은 워크플로 파일에서 참조하는 이름과 정확히 일치해야 합니다.

마무리

우리가 만든 것

우리가 다룬 모든 내용을 되돌아보면, 우리는 지루한 수동 프로세스였던 것을 자동화하는 포괄적인 AI 기반 RSS 피드 수집기를 구축했습니다. 이 애플리케이션은 7개의 Microsoft DevBlog 피드를 자동으로 모니터링하여 모든 새 기사가 게시되는 즉시 이를 포착합니다. 동일한 기사가 여러 피드에 나타나는 경우를 인식하여 중복 제거의 복잡성을 처리합니다.Semantic Kernel 및 Azure OpenAI를 기반으로 하는 AI 분석은 기사 콘텐츠를 읽고 이해하고, 요약을 생성하고, 주요 주제를 식별하고, 관련성을 설명하는 작업을 모두 자동으로 수행합니다. 가장 중요한 점은 최소한의 편집만으로 공유할 수 있는 매력적인 LinkedIn 게시물을 생성한다는 것입니다.

Telegram 통합은 검토할 새 콘텐츠가 있을 때마다 휴대폰으로 알림을 받을 수 있음을 의미합니다. 메시지를 보고 공유할지 결정하고 즉시 조치를 취할 수 있습니다.

그리고 일정에 따라 GitHub Actions에서 실행되기 때문에 어떤 작업도 기억할 필요가 없습니다. 시스템은 백그라운드에서 작동하며 공유할 가치가 있는 내용이 있을 때만 참여합니다.

이를 가능하게 한 기술

이 프로젝트는 각각 중요한 역할을 하는 여러 기술을 통합했습니다. .NET 9는 최신 언어 기능과 뛰어난 성능으로 견고한 기반을 제공했습니다. Semantic Kernel은 API 호출 및 응답 관리의 모든 복잡성을 처리하여 AI 통합을 간단하게 만들었습니다. Azure OpenAI는 기술 콘텐츠를 실제로 이해하고 분석하는 능력인 인텔리전스를 제공했습니다. HtmlAgilityPack은 웹 페이지에서 깨끗한 텍스트를 추출하는 복잡한 문제를 해결했습니다. System.ServiceModel.Syndication을 사용하면 RSS 구문 분석이 쉬워졌습니다. Telegram Bot API는 우리에게 신뢰할 수 있는 무료 알림을 제공했습니다. 그리고 GitHub Actions는 자동화된 예약 실행을 통해 이 모든 것을 하나로 묶었습니다.

비용에 대한 생각

여러분이 가질 수 있는 한 가지 질문은 다음과 같습니다. 이 작업을 실행하는 데 드는 비용은 얼마입니까? 대답은: 전혀 많지 않습니다.

텔레그램은 완전히 무료입니다. 봇을 통해 메시지를 보내는 데 비용이 들지 않습니다.

GitHub Actions는 공개 저장소에 무료로 제공됩니다. 프라이빗 리포지토리의 경우 무료 등급으로 월 2,000분을 이용할 수 있으며 이는 우리 사용 사례에 충분합니다.

Azure OpenAI는 유일한 유료 구성 요소이며 비용은 최소화됩니다. GPT-4o를 사용하면 일반적인 블로그 기사를 분석하는 데 1센트에서 3센트 정도의 비용이 듭니다. 한 달에 수십 개의 기사를 처리하더라도 AI 비용은 1달러 미만으로 보고 있습니다.

다음에는 어디로 갈 수 있나요?

이 솔루션은 내 요구 사항에 매우 적합하지만 이를 확장할 수 있는 방법은 많습니다. 여러 소셜 플랫폼에 대한 지원을 추가할 수 있습니다. LinkedIn 외에도 Twitter/X, Mastodon 또는 Bluesky에 게시할 수도 있습니다. 감정 분석을 구현하여 시간 경과에 따른 기사의 분위기를 추적하고 추세를 파악할 수 있습니다. 다양한 피드에 대해 다양한 프롬프트 템플릿을 허용하여 다양한 주제에 대해 다양한 스타일의 게시물을 생성할 수 있습니다. 텔레그램을 사용하는 대신 게시물을 검토하고 관리하기 위한 웹 대시보드를 구축할 수 있습니다. 게시된 콘텐츠에 대한 참여 지표를 추적하여 어떤 주제가 청중에게 가장 큰 공감을 불러일으키는지 확인할 수 있습니다.

최종 생각이 프로젝트에서 제가 가장 좋아하는 점은 제가 강력하게 믿는 철학을 구현한다는 것입니다. 자동화는 지루한 부분을 처리하고 창의적이고 의사결정적인 부분은 인간에게 맡겨야 한다는 것입니다. 시스템은 가져오기, 구문 분석, 분석, 생성 등 모든 힘든 작업을 수행하지만 공유하기 전에 모든 것을 검토합니다. AI가 생성한 게시물은 내가 맞춤화하고 개인화할 수 있는 출발점입니다.

.NET, Semantic Kernel 및 Azure OpenAI의 강력한 기능을 결합하여 품질과 일관성을 유지하면서 매주 수동 작업 시간을 절약하는 도구를 만들었습니다. 이는 일상 생활에 실질적인 변화를 가져오는 일종의 실용적인 자동화입니다.

비슷한 것을 만들거나 개선할 아이디어가 있다면 알려주시면 감사하겠습니다. LinkedIn에 언제든지 문의하세요!

즐거운 코딩 보내시고, 즐거운 크리스마스 보내세요! 🎄