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
This commit is contained in:
Yun Chan 2026-09-15 03:37:47 +09:00
parent 0e568f1f0a
commit a5a8da7424
37 changed files with 2841 additions and 10 deletions

View file

@ -0,0 +1,53 @@
using System;
using Paca.Core.Social;
using Xunit;
namespace Paca.Tests.Core;
/// <summary>
/// 제품 내 확산(공유) 루프 게이트: 다운로드 완료 화면에서 결과물을 공유하면
/// UTM 이 붙은 랜딩 URL 이 함께 퍼져 유입 루프가 닫히는지 검증한다.
/// </summary>
public class ShareLoopGateTests
{
[Fact]
public void Gate219_ShareCardBuilder_BuildsUtmTaggedLandingUrlAndShareText()
{
var url = ShareCardBuilder.BuildShareUrl("share_card");
Assert.Contains("https://mediabrowser.chanpaca.net/", url);
Assert.Contains("utm_source=paca_app", url);
Assert.Contains("utm_medium=share_card", url);
var text = ShareCardBuilder.BuildShareText("Big Buck Bunny.mp4");
Assert.Contains("Big Buck Bunny.mp4", text);
Assert.Contains(ShareCardBuilder.BuildShareUrl(), text);
Assert.Contains("미디어 브라우저", text);
}
[Fact]
public void Gate219_ShareCardBuilder_HandlesEmptyTitleAndBuildsQrTitle()
{
var text = ShareCardBuilder.BuildShareText(" ");
Assert.Contains("https://mediabrowser.chanpaca.net/", text);
Assert.DoesNotContain("''", text);
var qrTitle = ShareCardBuilder.BuildQrTitle("");
Assert.Contains("Paca", qrTitle);
var qrTitle2 = ShareCardBuilder.BuildQrTitle("clip.mp4");
Assert.Contains("clip.mp4", qrTitle2);
}
[Fact]
public void Gate219_DownloadShelf_DeclaresShareButtonWiredToShareCardBuilder()
{
var xaml = SourceTree.MainWindowXaml;
Assert.Contains("DownloadShelfShareBtn", xaml);
Assert.Contains("Click=\"DownloadShelfShareBtn_Click\"", xaml);
Assert.Contains("AutomationProperties.Name=\"다운로드 결과 공유\"", xaml);
var cs = SourceTree.MainWindowCs;
Assert.Contains("DownloadShelfShareBtn_Click", cs);
Assert.Contains("ShareCardBuilder", cs);
}
}

View file

@ -117,4 +117,181 @@ public class SiteSeoAeoGeoGateTests
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);
}
}
}