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
297 lines
14 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|