releases/tests/Paca.Tests/Infrastructure/SiteSeoAeoGeoGateTests.cs
Yun Chan a5a8da7424 feat(growth): ship search registration, long-tail SEO and in-app sharing
Register both Paca domains on Naver Search Advisor, Google Search Console and
Bing Webmaster Tools so the site can be indexed by every major engine, add
DASH and AES-128 how-to pages to capture protocol-specific searches, and let a
finished download share a UTM-tagged landing link from the completion shelf.
Package-manager manifests (winget, Chocolatey, Scoop) now carry verified
v1.2.0 hashes and product codes instead of placeholders. All 2,757 tests pass.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4.1-flash
2026-09-15 03:37:47 +09:00

297 lines
14 KiB
C#

using System;
using System.IO;
using System.Text.Json;
using System.Xml.Linq;
using Xunit;
namespace Paca.Tests.Infrastructure;
/// <summary>
/// PACA 웹사이트 및 제품의 SEO(검색엔진), AEO(답변엔진), GEO(생성형 AI) 최적화 인프라 무결성 검증 게이트
/// </summary>
public class SiteSeoAeoGeoGateTests
{
private static string SiteDir => Path.Combine(SourceTree.Root, "site");
[Fact]
public void Gate211_SchemaOrgStructuredDataIntegrity()
{
// 1. index.html의 SoftwareApplication 및 FAQPage Schema.org 검증
var indexPath = Path.Combine(SiteDir, "index.html");
Assert.True(File.Exists(indexPath), "site/index.html 파일이 존재해야 합니다.");
var indexHtml = File.ReadAllText(indexPath);
Assert.Contains("application/ld+json", indexHtml);
Assert.Contains("\"@type\": \"SoftwareApplication\"", indexHtml);
Assert.Contains("\"offers\"", indexHtml);
Assert.Contains("\"operatingSystem\"", indexHtml);
// 2. how-to-download-m3u8.html (HLS m3u8 다운로드 완벽 가이드) 및 HowTo Schema.org 검증
var m3u8GuidePath = Path.Combine(SiteDir, "how-to-download-m3u8.html");
Assert.True(File.Exists(m3u8GuidePath), "site/how-to-download-m3u8.html 랜딩 페이지가 존재해야 합니다.");
var m3u8Html = File.ReadAllText(m3u8GuidePath);
Assert.Contains("\"@type\": \"HowTo\"", m3u8Html);
Assert.Contains("\"step\"", m3u8Html);
Assert.Contains("m3u8", m3u8Html);
Assert.Contains("HLS", m3u8Html);
Assert.Contains("MP4", m3u8Html);
// 3. vs-idm-alternative.html (IDM / 4K Video Downloader 대체 비교 가이드) 검증
var vsIdmPath = Path.Combine(SiteDir, "vs-idm-alternative.html");
Assert.True(File.Exists(vsIdmPath), "site/vs-idm-alternative.html 랜딩 페이지가 존재해야 합니다.");
var vsIdmHtml = File.ReadAllText(vsIdmPath);
Assert.Contains("IDM", vsIdmHtml);
Assert.Contains("4K Video Downloader", vsIdmHtml);
Assert.Contains("Internet Download Manager", vsIdmHtml);
Assert.Contains("\"@type\": \"FAQPage\"", vsIdmHtml);
}
[Fact]
public void Gate212_AiCrawlerAndLlmStandardIntegrity()
{
// 1. robots.txt 검증: 모든 AI 크롤러 봇 (GPTBot, PerplexityBot, ClaudeBot, Google-Extended 등) 접근 허용
var robotsPath = Path.Combine(SiteDir, "robots.txt");
Assert.True(File.Exists(robotsPath), "site/robots.txt 파일이 존재해야 합니다.");
var robotsTxt = File.ReadAllText(robotsPath);
Assert.Contains("User-agent: *", robotsTxt);
Assert.Contains("GPTBot", robotsTxt);
Assert.Contains("PerplexityBot", robotsTxt);
Assert.Contains("ClaudeBot", robotsTxt);
Assert.Contains("Allow: /", robotsTxt);
Assert.Contains("llms.txt", robotsTxt);
Assert.Contains("sitemap.xml", robotsTxt);
// 2. llms.txt (Answer.AI 표준 AI 모델용 제품 요약) 검증
var llmsPath = Path.Combine(SiteDir, "llms.txt");
Assert.True(File.Exists(llmsPath), "site/llms.txt 파일이 존재해야 합니다.");
var llmsTxt = File.ReadAllText(llmsPath);
Assert.Contains("# Paca", llmsTxt);
Assert.Contains("HLS", llmsTxt);
Assert.Contains("DASH", llmsTxt);
Assert.Contains("AES-128", llmsTxt);
Assert.Contains("ffmpeg", llmsTxt);
Assert.Contains("llms-full.txt", llmsTxt);
// 3. llms-full.txt (전체 상세 기술 명세 및 인용 문서) 검증
var llmsFullPath = Path.Combine(SiteDir, "llms-full.txt");
Assert.True(File.Exists(llmsFullPath), "site/llms-full.txt 파일이 존재해야 합니다.");
var llmsFullTxt = File.ReadAllText(llmsFullPath);
Assert.True(llmsFullTxt.Length >= 2500, "llms-full.txt는 AI 모델이 충분히 인용할 수 있도록 2,500자 이상의 상세 기술 명세를 담아야 합니다.");
Assert.Contains("IDM", llmsFullTxt);
Assert.Contains("Benchmark", llmsFullTxt);
Assert.Contains("AES-128", llmsFullTxt);
Assert.Contains("FAQ", llmsFullTxt);
}
[Fact]
public void Gate213_AeoComparisonMatrixAndSitemapIntegrity()
{
// 1. AEO 경쟁 툴 비교 테이블 (Comparison Matrix) 존재성 검증
var vsIdmPath = Path.Combine(SiteDir, "vs-idm-alternative.html");
var indexPath = Path.Combine(SiteDir, "index.html");
Assert.True(File.Exists(vsIdmPath));
var vsHtml = File.ReadAllText(vsIdmPath);
Assert.Contains("<table", vsHtml);
Assert.Contains("Paca", vsHtml);
Assert.Contains("IDM", vsHtml);
Assert.Contains("4K Video Downloader", vsHtml);
Assert.Contains("크롬 확장 프로그램", vsHtml);
// 2. sitemap.xml 전체 URL 색인 검증
var sitemapPath = Path.Combine(SiteDir, "sitemap.xml");
Assert.True(File.Exists(sitemapPath), "site/sitemap.xml 파일이 존재해야 합니다.");
var sitemapXml = File.ReadAllText(sitemapPath);
Assert.Contains("how-to-download-m3u8.html", sitemapXml);
Assert.Contains("vs-idm-alternative.html", sitemapXml);
Assert.Contains("releases.html", sitemapXml);
Assert.Contains("guide.html", sitemapXml);
// 3. index.html의 다국어 (ko/en) 메타데이터 및 글로벌 고단가 트래픽 타깃 검증
var indexHtml = File.ReadAllText(indexPath);
Assert.Contains("hreflang", indexHtml);
}
[Fact]
public void Gate214_AiCrawlerCoverageIndexNowAndFeedIntegrity()
{
// 1. robots.txt: 2026 신규 생성형 AI 검색 크롤러(OAI-SearchBot, ChatGPT-User 등) 및 Applebot-Extended 허용
var robotsTxt = File.ReadAllText(Path.Combine(SiteDir, "robots.txt"));
Assert.Contains("OAI-SearchBot", robotsTxt);
Assert.Contains("ChatGPT-User", robotsTxt);
Assert.Contains("Applebot-Extended", robotsTxt);
// 2. IndexNow 키 파일: 루트에 {key}.txt 가 존재하고 내용이 파일명(키)과 정확히 일치해야 한다.
const string indexNowKey = "e9f2c7a4b1d8460f9c3e2a7b5d1f08c4";
var keyFilePath = Path.Combine(SiteDir, indexNowKey + ".txt");
Assert.True(File.Exists(keyFilePath), "site/ 에 IndexNow 키 파일 {key}.txt 가 존재해야 합니다.");
Assert.Equal(indexNowKey, File.ReadAllText(keyFilePath).Trim());
// 3. IndexNow ping 스크립트: 사이트맵 URL 을 엔진(api.indexnow.org / bing / naver)에 제출해야 한다.
var pingScriptPath = Path.Combine(SourceTree.Root, "build", "indexnow-ping.ps1");
Assert.True(File.Exists(pingScriptPath), "build/indexnow-ping.ps1 스크립트가 존재해야 합니다.");
var pingScript = File.ReadAllText(pingScriptPath);
Assert.Contains("api.indexnow.org", pingScript);
Assert.Contains(indexNowKey, pingScript);
// 4. Naver Search Advisor 대응 RSS 피드: sitemap + RSS 2.0 을 함께 제공해야 한다.
var feedPath = Path.Combine(SiteDir, "feed.xml");
Assert.True(File.Exists(feedPath), "site/feed.xml (RSS 2.0) 이 존재해야 합니다.");
var feed = File.ReadAllText(feedPath);
Assert.Contains("<rss", feed);
Assert.Contains("<channel>", feed);
Assert.Contains("<item>", feed);
Assert.Contains("mediabrowser.chanpaca.net", feed);
}
[Fact]
public void Gate215_EnglishLocalizationAndValidHreflangIntegrity()
{
// 1. 실제 영문 랜딩 페이지 존재 및 언어 선언 (Hreflang 은 실제 번역 페이지가 있어야만 유효하다)
var enPath = Path.Combine(SiteDir, "en", "index.html");
Assert.True(File.Exists(enPath), "site/en/index.html 영문 랜딩 페이지가 존재해야 합니다.");
var enHtml = File.ReadAllText(enPath);
Assert.Contains("lang=\"en\"", enHtml);
Assert.Contains("https://mediabrowser.chanpaca.net/en/", enHtml);
Assert.Contains("hreflang=\"ko\"", enHtml);
// 2. 한국어 홈: hreflang en 은 실제 영문 URL(/en/)을 가리켜야 한다 (자기 참조 금지).
var indexHtml = File.ReadAllText(Path.Combine(SiteDir, "index.html"));
Assert.Contains("hreflang=\"en\" href=\"https://mediabrowser.chanpaca.net/en/\"", indexHtml);
Assert.Contains("hreflang=\"ko\" href=\"https://mediabrowser.chanpaca.net/\"", indexHtml);
// 3. 영문 페이지도 상호 참조(양방향) hreflang 및 자기 canonical 을 지녀야 한다.
Assert.Contains("<link rel=\"canonical\" href=\"https://mediabrowser.chanpaca.net/en/\">", enHtml);
Assert.Contains("hreflang=\"x-default\"", enHtml);
// 4. 번역본이 없는 하위 페이지에는 존재하지 않는 영문 hreflang 을 걸지 않는다 (중복 콘텐츠/무효 참조 방지).
foreach (var page in new[] { "vs-idm-alternative.html", "how-to-download-m3u8.html" })
{
var html = File.ReadAllText(Path.Combine(SiteDir, page));
Assert.DoesNotContain("hreflang=\"en\"", html);
}
}
[Fact]
public void Gate216_EntityGraphAndVisibleAnswerFirstFaqIntegrity()
{
var indexPath = Path.Combine(SiteDir, "index.html");
var indexHtml = File.ReadAllText(indexPath);
// 1. 엔티티 그래프: Organization 노드 + sameAs 교차 검증 링크 + publisher 연결 (AI 엔티티 인식/신뢰 확보)
Assert.Contains("\"@type\": \"Organization\"", indexHtml);
Assert.Contains("\"sameAs\"", indexHtml);
Assert.Contains("\"publisher\"", indexHtml);
// 2. 신선도 신호: dateModified 를 명시해야 한다 (ChatGPT/Perplexity 의 최신성 선호 대응)
Assert.Contains("\"dateModified\"", indexHtml);
// 3. 답변 우선(Answer-first) 가시적 FAQ: JSON-LD 뿐 아니라 실제 화면 본문에도 Q&A 가 노출되어야 인용된다.
Assert.Contains("id=\"faq\"", indexHtml);
Assert.Contains("<h3>Paca는 정말 100% 무료인가요?</h3>", indexHtml);
Assert.Contains("IDM(Internet Download Manager) 대신 사용할 수 있나요?", indexHtml);
// 4. AI 크롤러는 숨김 요소를 읽지 않으므로, 비교표에 의미론적 caption 을 부여해 표 단위 인용을 유도한다.
var vsHtml = File.ReadAllText(Path.Combine(SiteDir, "vs-idm-alternative.html"));
Assert.Contains("<caption", vsHtml);
}
[Fact]
public void Gate217_CompetitorComparisonLandingPagesIntegrity()
{
// 고의도 "[경쟁 도구] 대안" 검색을 흡수하는 경쟁자별 비교 랜딩 페이지 무결성.
var pages = new (string File, string Competitor)[]
{
("vs-yt-dlp.html", "yt-dlp"),
("vs-jdownloader.html", "JDownloader")
};
var sitemap = File.ReadAllText(Path.Combine(SiteDir, "sitemap.xml"));
var llms = File.ReadAllText(Path.Combine(SiteDir, "llms.txt"));
foreach (var (file, competitor) in pages)
{
var path = Path.Combine(SiteDir, file);
Assert.True(File.Exists(path), $"site/{file} 경쟁자 비교 랜딩 페이지가 존재해야 합니다.");
var html = File.ReadAllText(path);
Assert.Contains("lang=\"ko\"", html);
Assert.Contains($"https://mediabrowser.chanpaca.net/{file}", html);
Assert.Contains("rel=\"canonical\"", html);
Assert.Contains("hreflang=\"ko\"", html);
Assert.DoesNotContain("hreflang=\"en\"", html);
Assert.Contains(competitor, html);
Assert.Contains("<table", html);
Assert.Contains("<caption", html);
Assert.Contains("\"@type\": \"FAQPage\"", html);
Assert.Contains(file, sitemap);
Assert.Contains(file, llms);
}
}
[Fact]
public void Gate218_ProtocolLongTailHowToPagesIntegrity()
{
// 프로토콜·문제 중심 롱테일 how-to: 각 페이지는 고유 데이터(프로토콜 명세·수치 표)를 지녀야 저품질 대량 생성으로 분류되지 않는다.
var pages = new (string File, string[] Tokens)[]
{
("how-to-download-dash-mpd.html", new[] { "DASH", "MPD", "SegmentTemplate" }),
("how-to-download-hls-aes-128.html", new[] { "AES-128", "EXT-X-KEY", "HLS" })
};
var sitemap = File.ReadAllText(Path.Combine(SiteDir, "sitemap.xml"));
var llms = File.ReadAllText(Path.Combine(SiteDir, "llms.txt"));
var feed = File.ReadAllText(Path.Combine(SiteDir, "feed.xml"));
foreach (var (file, tokens) in pages)
{
var path = Path.Combine(SiteDir, file);
Assert.True(File.Exists(path), $"site/{file} 테일 how-to 페이지가 존재해야 합니다.");
var html = File.ReadAllText(path);
// 언어/정규 URL/유효 hreflang (번역본 없는 하위 페이지에 무효 영문 hreflang 금지)
Assert.Contains("lang=\"ko\"", html);
Assert.Contains($"https://mediabrowser.chanpaca.net/{file}", html);
Assert.Contains("rel=\"canonical\"", html);
Assert.Contains("hreflang=\"ko\"", html);
Assert.DoesNotContain("hreflang=\"en\"", html);
// AEO: HowTo 구조화 데이터 + 가시적 답변 + FAQ
Assert.Contains("\"@type\": \"HowTo\"", html);
Assert.Contains("\"step\"", html);
Assert.Contains("\"@type\": \"FAQPage\"", html);
Assert.Contains("id=\"faq\"", html);
// 고유 데이터: 의미론적 비교/명세 표 (caption 필수)
Assert.Contains("<table", html);
Assert.Contains("<caption", html);
// 프로토 고유 명세 토큰
foreach (var token in tokens)
{
Assert.Contains(token, html);
}
// 내부 링크(상호 연결) 및 색인 등록
Assert.Contains("how-to-download-m3u8.html", html);
Assert.Contains(file, sitemap);
Assert.Contains(file, llms);
Assert.Contains(file, feed);
}
// m3u8 가이드에서 신규 롱테일 페이지로 내부 링크가 연결되어야 한다(고아 페이지 방지).
var m3u8Html = File.ReadAllText(Path.Combine(SiteDir, "how-to-download-m3u8.html"));
foreach (var (file, _) in pages)
{
Assert.Contains(file, m3u8Html);
}
}
}