geo-checker 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -305,10 +305,12 @@ async function buildContext(url, opts = {}) {
305
305
  "Site appears to be JS-rendered (sparse body + SPA root element). Re-run with --render for accurate results."
306
306
  );
307
307
  }
308
- const [robotsRaw, llmsRaw, llmsFullRaw] = await Promise.all([
308
+ const [robotsRaw, llmsRaw, llmsFullRaw, skillMdRaw, agentPermissionsRaw] = await Promise.all([
309
309
  fetchText(`${origin}/robots.txt`, opts),
310
310
  fetchText(`${origin}/llms.txt`, opts),
311
- fetchText(`${origin}/llms-full.txt`, opts)
311
+ fetchText(`${origin}/llms-full.txt`, opts),
312
+ fetchText(`${origin}/skill.md`, opts),
313
+ fetchText(`${origin}/agent-permissions.json`, opts)
312
314
  ]);
313
315
  let sitemapUrl = null;
314
316
  const robots = robotsRaw ? parseRobots(robotsRaw) : null;
@@ -316,6 +318,13 @@ async function buildContext(url, opts = {}) {
316
318
  if (!sitemapUrl) sitemapUrl = `${origin}/sitemap.xml`;
317
319
  const sitemapRaw = await fetchText(sitemapUrl, opts);
318
320
  const sitemap = sitemapRaw ? parseSitemap(sitemapRaw) : null;
321
+ let agentPermissions = null;
322
+ if (agentPermissionsRaw && agentPermissionsRaw.trim().length > 0) {
323
+ try {
324
+ agentPermissions = JSON.parse(agentPermissionsRaw);
325
+ } catch {
326
+ }
327
+ }
319
328
  return {
320
329
  url,
321
330
  finalUrl,
@@ -330,7 +339,9 @@ async function buildContext(url, opts = {}) {
330
339
  jsonLd: extractJsonLd($),
331
340
  renderMode,
332
341
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
333
- warnings
342
+ warnings,
343
+ skillMd: skillMdRaw && skillMdRaw.trim().length > 0 ? skillMdRaw : null,
344
+ agentPermissions
334
345
  };
335
346
  }
336
347
 
@@ -339,10 +350,11 @@ function defineRule(rule) {
339
350
  return rule;
340
351
  }
341
352
  var CATEGORY_WEIGHTS = {
342
- crawler: 25,
343
- "structured-data": 30,
344
- citation: 25,
345
- content: 20
353
+ crawler: 20,
354
+ "structured-data": 25,
355
+ citation: 20,
356
+ content: 15,
357
+ aeo: 20
346
358
  };
347
359
 
348
360
  // src/engine.ts
@@ -356,7 +368,8 @@ async function runRules(ctx, rules, opts = {}) {
356
368
  crawler: { score: 0, weight: weights.crawler, results: [] },
357
369
  "structured-data": { score: 0, weight: weights["structured-data"], results: [] },
358
370
  citation: { score: 0, weight: weights.citation, results: [] },
359
- content: { score: 0, weight: weights.content, results: [] }
371
+ content: { score: 0, weight: weights.content, results: [] },
372
+ aeo: { score: 0, weight: weights.aeo, results: [] }
360
373
  };
361
374
  for (const rule of rules) {
362
375
  if (onlySet && !onlySet.has(rule.id) && (!rule.stableId || !onlySet.has(rule.stableId))) continue;
@@ -381,6 +394,7 @@ async function runRules(ctx, rules, opts = {}) {
381
394
  durationMs
382
395
  };
383
396
  if (rule.stableId !== void 0) entry.stableId = rule.stableId;
397
+ if (rule.title_ko !== void 0) entry.title_ko = rule.title_ko;
384
398
  if (rule.group !== void 0) entry.group = rule.group;
385
399
  if (rule.impact !== void 0) entry.impact = rule.impact;
386
400
  if (rule.effort !== void 0) entry.effort = rule.effort;
@@ -443,13 +457,15 @@ var httpsRule = defineRule({
443
457
  effort: "medium",
444
458
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerhttps",
445
459
  title: "Site is served over HTTPS",
460
+ title_ko: "\uC0AC\uC774\uD2B8\uAC00 HTTPS\uB85C \uC81C\uACF5\uB428",
446
461
  description: "AI crawlers treat HTTPS pages as more trustworthy and some skip plain HTTP entirely.",
447
462
  run(ctx) {
448
463
  const isHttps = ctx.finalUrl.startsWith("https://");
449
- return isHttps ? { status: "pass", score: 1, rationale: "Final URL uses HTTPS." } : {
464
+ return isHttps ? { status: "pass", score: 1, rationale: "Final URL uses HTTPS.", rationale_ko: "\uCD5C\uC885 URL\uC774 HTTPS\uB97C \uC0AC\uC6A9\uD569\uB2C8\uB2E4." } : {
450
465
  status: "fail",
451
466
  score: 0,
452
467
  rationale: "Final URL does not use HTTPS. Redirect HTTP \u2192 HTTPS site-wide.",
468
+ rationale_ko: "\uCD5C\uC885 URL\uC774 HTTPS\uB97C \uC0AC\uC6A9\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uC0AC\uC774\uD2B8 \uC804\uCCB4\uB97C HTTP \u2192 HTTPS\uB85C \uB9AC\uB2E4\uC774\uB809\uD2B8\uD558\uC138\uC694.",
453
469
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
454
470
  };
455
471
  }
@@ -466,15 +482,17 @@ var robotsReachableRule = defineRule({
466
482
  effort: "low",
467
483
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerrobots-reachable",
468
484
  title: "robots.txt is reachable",
485
+ title_ko: "robots.txt \uC811\uADFC \uAC00\uB2A5 \uC5EC\uBD80",
469
486
  description: "A reachable robots.txt lets crawlers confirm their permissions; missing file is treated as allow-all but blocks explicit signalling.",
470
487
  run(ctx) {
471
488
  if (ctx.robots) {
472
- return { status: "pass", score: 1, rationale: "robots.txt returned successfully." };
489
+ return { status: "pass", score: 1, rationale: "robots.txt returned successfully.", rationale_ko: "robots.txt\uAC00 \uC815\uC0C1\uC801\uC73C\uB85C \uC751\uB2F5\uD569\uB2C8\uB2E4." };
473
490
  }
474
491
  return {
475
492
  status: "warn",
476
493
  score: 0.3,
477
494
  rationale: "robots.txt is missing. Add one even if empty to explicitly signal crawl policy.",
495
+ rationale_ko: "robots.txt\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. \uD06C\uB864 \uC815\uCC45\uC744 \uBA85\uC2DC\uC801\uC73C\uB85C \uC54C\uB9AC\uB824\uBA74 \uBE44\uC5B4 \uC788\uB354\uB77C\uB3C4 \uCD94\uAC00\uD558\uC138\uC694.",
478
496
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
479
497
  };
480
498
  }
@@ -522,13 +540,15 @@ var robotsAiAllowRule = defineRule({
522
540
  effort: "low",
523
541
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerrobots-ai-allow",
524
542
  title: "AI crawlers are allowed",
543
+ title_ko: "AI \uD06C\uB864\uB7EC \uC811\uADFC \uD5C8\uC6A9 \uC5EC\uBD80",
525
544
  description: "Major AI search and training crawlers (17 bots incl. GPTBot, OAI-SearchBot, Google-Extended, ClaudeBot, PerplexityBot, Applebot-Extended, Meta-ExternalAgent, Bytespider, DuckAssistBot, YouBot) must be allowed to index the homepage.",
526
545
  run(ctx) {
527
546
  if (!ctx.robots) {
528
547
  return {
529
548
  status: "warn",
530
549
  score: 0.5,
531
- rationale: "robots.txt missing; AI crawlers default to allow, but explicit allow is recommended."
550
+ rationale: "robots.txt missing; AI crawlers default to allow, but explicit allow is recommended.",
551
+ rationale_ko: "robots.txt\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. AI \uD06C\uB864\uB7EC\uB294 \uAE30\uBCF8\uC801\uC73C\uB85C \uD5C8\uC6A9\uB418\uC9C0\uB9CC, \uBA85\uC2DC\uC801 \uD5C8\uC6A9\uC744 \uAD8C\uC7A5\uD569\uB2C8\uB2E4."
532
552
  };
533
553
  }
534
554
  const path = new URL(ctx.finalUrl).pathname || "/";
@@ -546,6 +566,7 @@ var robotsAiAllowRule = defineRule({
546
566
  status: "fail",
547
567
  score: Math.max(0, 1 - blocked.length / AI_BOTS.length),
548
568
  rationale: `Blocked: ${blocked.join(", ")}. Remove the Disallow or add an explicit Allow for these user-agents.`,
569
+ rationale_ko: `\uCC28\uB2E8\uB428: ${blocked.join(", ")}. \uD574\uB2F9 User-agent\uC758 Disallow\uB97C \uC81C\uAC70\uD558\uAC70\uB098 \uBA85\uC2DC\uC801 Allow\uB97C \uCD94\uAC00\uD558\uC138\uC694.`,
549
570
  evidence: { blocked, mentioned, totalBots: AI_BOTS.length },
550
571
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
551
572
  };
@@ -554,13 +575,15 @@ var robotsAiAllowRule = defineRule({
554
575
  return {
555
576
  status: "warn",
556
577
  score: 0.6,
557
- rationale: `All ${AI_BOTS.length} AI crawlers reach the page via default rules, but none are explicitly listed. Consider explicit Allow entries.`
578
+ rationale: `All ${AI_BOTS.length} AI crawlers reach the page via default rules, but none are explicitly listed. Consider explicit Allow entries.`,
579
+ rationale_ko: `${AI_BOTS.length}\uAC1C AI \uD06C\uB864\uB7EC \uBAA8\uB450 \uAE30\uBCF8 \uADDC\uCE59\uC73C\uB85C \uC811\uADFC \uAC00\uB2A5\uD558\uC9C0\uB9CC, \uBA85\uC2DC\uC801\uC73C\uB85C \uD5C8\uC6A9\uB41C \uBD07\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. \uBA85\uC2DC\uC801 Allow \uD56D\uBAA9 \uCD94\uAC00\uB97C \uAD8C\uC7A5\uD569\uB2C8\uB2E4.`
558
580
  };
559
581
  }
560
582
  return {
561
583
  status: "pass",
562
584
  score: 1,
563
585
  rationale: `All ${AI_BOTS.length} AI crawlers can reach the page; ${mentioned.length} explicitly listed.`,
586
+ rationale_ko: `${AI_BOTS.length}\uAC1C AI \uD06C\uB864\uB7EC \uBAA8\uB450 \uC811\uADFC \uAC00\uB2A5\uD558\uBA70, ${mentioned.length}\uAC1C\uAC00 \uBA85\uC2DC\uC801\uC73C\uB85C \uD5C8\uC6A9\uB418\uC5B4 \uC788\uC2B5\uB2C8\uB2E4.`,
564
587
  evidence: { mentioned, totalBots: AI_BOTS.length }
565
588
  };
566
589
  }
@@ -577,15 +600,17 @@ var llmsTxtPresentRule = defineRule({
577
600
  effort: "medium",
578
601
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerllms-txt-present",
579
602
  title: "llms.txt is present",
603
+ title_ko: "llms.txt \uD30C\uC77C \uC874\uC7AC \uC5EC\uBD80",
580
604
  description: "An /llms.txt file at the site root gives AI assistants a curated map of the most citation-worthy pages.",
581
605
  run(ctx) {
582
606
  if (ctx.llmsTxt) {
583
- return { status: "pass", score: 1, rationale: "llms.txt found at site root." };
607
+ return { status: "pass", score: 1, rationale: "llms.txt found at site root.", rationale_ko: "llms.txt\uAC00 \uC0AC\uC774\uD2B8 \uB8E8\uD2B8\uC5D0 \uC874\uC7AC\uD569\uB2C8\uB2E4." };
584
608
  }
585
609
  return {
586
610
  status: "warn",
587
611
  score: 0,
588
612
  rationale: "No /llms.txt found. Add one to curate the pages AI assistants should read.",
613
+ rationale_ko: "/llms.txt\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. AI \uC5B4\uC2DC\uC2A4\uD134\uD2B8\uAC00 \uC77D\uC5B4\uC57C \uD560 \uD398\uC774\uC9C0\uB97C \uD050\uB808\uC774\uC158\uD558\uB824\uBA74 \uCD94\uAC00\uD558\uC138\uC694.",
589
614
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
590
615
  };
591
616
  }
@@ -602,10 +627,11 @@ var llmsTxtWellformedRule = defineRule({
602
627
  effort: "low",
603
628
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerllms-txt-wellformed",
604
629
  title: "llms.txt follows the spec",
630
+ title_ko: "llms.txt \uC2A4\uD399 \uC900\uC218 \uC5EC\uBD80",
605
631
  description: "Must start with an H1 project title, then a brief summary, then at least one H2 section containing link items.",
606
632
  run(ctx) {
607
633
  if (!ctx.llmsTxt) {
608
- return { status: "skip", score: 0, rationale: "No llms.txt to validate." };
634
+ return { status: "skip", score: 0, rationale: "No llms.txt to validate.", rationale_ko: "\uAC80\uC99D\uD560 llms.txt\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
609
635
  }
610
636
  const check = isLlmsTxtWellFormed(ctx.llmsTxt);
611
637
  if (check.ok) {
@@ -613,13 +639,15 @@ var llmsTxtWellformedRule = defineRule({
613
639
  return {
614
640
  status: "pass",
615
641
  score: 1,
616
- rationale: `Well-formed with ${ctx.llmsTxt.sections.length} section(s) and ${totalLinks} link(s).`
642
+ rationale: `Well-formed with ${ctx.llmsTxt.sections.length} section(s) and ${totalLinks} link(s).`,
643
+ rationale_ko: `\uC2A4\uD399\uC5D0 \uB9DE\uAC8C \uC791\uC131\uB428 (\uC139\uC158 ${ctx.llmsTxt.sections.length}\uAC1C, \uB9C1\uD06C ${totalLinks}\uAC1C).`
617
644
  };
618
645
  }
619
646
  return {
620
647
  status: "warn",
621
648
  score: 0.3,
622
649
  rationale: `llms.txt does not fully match the spec: ${check.reason}.`,
650
+ rationale_ko: `llms.txt\uAC00 \uC2A4\uD399\uC744 \uC644\uC804\uD788 \uB530\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4: ${check.reason}.`,
623
651
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
624
652
  };
625
653
  }
@@ -636,13 +664,15 @@ var llmsFullTxtRule = defineRule({
636
664
  effort: "medium",
637
665
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerllms-full-txt",
638
666
  title: "llms-full.txt provides full-content mirror",
667
+ title_ko: "llms-full.txt \uC804\uCCB4 \uCF58\uD150\uCE20 \uBBF8\uB7EC \uC81C\uACF5 \uC5EC\uBD80",
639
668
  description: "Complement /llms.txt with /llms-full.txt containing the full body of every cited page. AI assistants can ingest it in one request instead of crawling every URL.",
640
669
  run(ctx) {
641
670
  if (ctx.llmsFullTxt && ctx.llmsFullTxt.length > 200) {
642
671
  return {
643
672
  status: "pass",
644
673
  score: 1,
645
- rationale: `/llms-full.txt found (${ctx.llmsFullTxt.length.toLocaleString()} chars).`
674
+ rationale: `/llms-full.txt found (${ctx.llmsFullTxt.length.toLocaleString()} chars).`,
675
+ rationale_ko: `/llms-full.txt\uAC00 \uC874\uC7AC\uD569\uB2C8\uB2E4 (${ctx.llmsFullTxt.length.toLocaleString()}\uC790).`
646
676
  };
647
677
  }
648
678
  if (ctx.llmsFullTxt) {
@@ -650,6 +680,7 @@ var llmsFullTxtRule = defineRule({
650
680
  status: "warn",
651
681
  score: 0.5,
652
682
  rationale: `/llms-full.txt found but very short (${ctx.llmsFullTxt.length} chars). Consider expanding with page bodies.`,
683
+ rationale_ko: `/llms-full.txt\uAC00 \uC788\uC9C0\uB9CC \uB108\uBB34 \uC9E7\uC2B5\uB2C8\uB2E4 (${ctx.llmsFullTxt.length}\uC790). \uD398\uC774\uC9C0 \uBCF8\uBB38\uC73C\uB85C \uB0B4\uC6A9\uC744 \uBCF4\uAC15\uD558\uC138\uC694.`,
653
684
  fixHint: "Mirror full article bodies into /llms-full.txt so AI assistants can quote without re-crawling."
654
685
  };
655
686
  }
@@ -657,6 +688,7 @@ var llmsFullTxtRule = defineRule({
657
688
  status: "warn",
658
689
  score: 0,
659
690
  rationale: "No /llms-full.txt found. Adding one lets AI assistants ingest the full corpus in a single request.",
691
+ rationale_ko: "/llms-full.txt\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. \uCD94\uAC00\uD558\uBA74 AI \uC5B4\uC2DC\uC2A4\uD134\uD2B8\uAC00 \uC804\uCCB4 \uCF58\uD150\uCE20\uB97C \uD55C \uBC88\uC758 \uC694\uCCAD\uC73C\uB85C \uC218\uC9D1\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4.",
660
692
  fixHint: "Publish /llms-full.txt alongside /llms.txt with the full body text of your top pages.",
661
693
  estimatedImpact: 1
662
694
  };
@@ -674,19 +706,22 @@ var sitemapPresentRule = defineRule({
674
706
  effort: "low",
675
707
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlersitemap-present",
676
708
  title: "sitemap.xml is present",
709
+ title_ko: "sitemap.xml \uC874\uC7AC \uC5EC\uBD80",
677
710
  description: "A sitemap helps AI crawlers discover and prioritise pages; many crawlers short-circuit discovery without one.",
678
711
  run(ctx) {
679
712
  if (ctx.sitemap && ctx.sitemap.urls.length > 0) {
680
713
  return {
681
714
  status: "pass",
682
715
  score: 1,
683
- rationale: `Sitemap found with ${ctx.sitemap.urls.length} URL(s).`
716
+ rationale: `Sitemap found with ${ctx.sitemap.urls.length} URL(s).`,
717
+ rationale_ko: `\uC0AC\uC774\uD2B8\uB9F5\uC5D0 URL\uC774 ${ctx.sitemap.urls.length}\uAC1C \uC788\uC2B5\uB2C8\uB2E4.`
684
718
  };
685
719
  }
686
720
  return {
687
721
  status: "warn",
688
722
  score: 0.2,
689
723
  rationale: "No sitemap.xml found (checked /sitemap.xml and Sitemap: directive in robots.txt).",
724
+ rationale_ko: "sitemap.xml\uC774 \uC5C6\uC2B5\uB2C8\uB2E4 (/sitemap.xml \uBC0F robots.txt\uC758 Sitemap: \uC9C0\uC2DC\uC5B4\uB97C \uD655\uC778\uD588\uC2B5\uB2C8\uB2E4).",
690
725
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
691
726
  };
692
727
  }
@@ -714,15 +749,17 @@ var jsonLdPresentRule = defineRule({
714
749
  effort: "medium",
715
750
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdjsonld-present",
716
751
  title: "JSON-LD structured data is present",
752
+ title_ko: "JSON-LD \uAD6C\uC870\uD654 \uB370\uC774\uD130 \uC874\uC7AC \uC5EC\uBD80",
717
753
  description: 'At least one <script type="application/ld+json"> block is the primary way AI engines map your page to an entity.',
718
754
  run(ctx) {
719
755
  if (ctx.jsonLd.length > 0) {
720
- return { status: "pass", score: 1, rationale: `Found ${ctx.jsonLd.length} JSON-LD block(s).` };
756
+ return { status: "pass", score: 1, rationale: `Found ${ctx.jsonLd.length} JSON-LD block(s).`, rationale_ko: `JSON-LD \uBE14\uB85D\uC774 ${ctx.jsonLd.length}\uAC1C \uC788\uC2B5\uB2C8\uB2E4.` };
721
757
  }
722
758
  return {
723
759
  status: "fail",
724
760
  score: 0,
725
761
  rationale: "No JSON-LD blocks found. Add schema.org structured data.",
762
+ rationale_ko: "JSON-LD \uBE14\uB85D\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. schema.org \uAD6C\uC870\uD654 \uB370\uC774\uD130\uB97C \uCD94\uAC00\uD558\uC138\uC694.",
726
763
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
727
764
  };
728
765
  }
@@ -798,20 +835,22 @@ var jsonLdValidJsonRule = defineRule({
798
835
  effort: "low",
799
836
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdjsonld-valid-json",
800
837
  title: "JSON-LD blocks parse as valid JSON",
838
+ title_ko: "JSON-LD \uBE14\uB85D\uC758 JSON \uC720\uD6A8\uC131",
801
839
  description: "Malformed JSON in an ld+json block is silently ignored by most consumers \u2014 a costly silent failure.",
802
840
  run(ctx) {
803
841
  if (ctx.jsonLd.length === 0) {
804
- return { status: "skip", score: 0, rationale: "No JSON-LD to validate." };
842
+ return { status: "skip", score: 0, rationale: "No JSON-LD to validate.", rationale_ko: "\uAC80\uC99D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
805
843
  }
806
844
  if (hasParseError(ctx.jsonLd)) {
807
845
  return {
808
846
  status: "fail",
809
847
  score: 0,
810
848
  rationale: "One or more JSON-LD blocks failed to parse.",
849
+ rationale_ko: "JSON-LD \uBE14\uB85D \uD558\uB098 \uC774\uC0C1\uC744 \uD30C\uC2F1\uD558\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.",
811
850
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
812
851
  };
813
852
  }
814
- return { status: "pass", score: 1, rationale: "All JSON-LD blocks parse cleanly." };
853
+ return { status: "pass", score: 1, rationale: "All JSON-LD blocks parse cleanly.", rationale_ko: "\uBAA8\uB4E0 JSON-LD \uBE14\uB85D\uC774 \uC62C\uBC14\uB974\uAC8C \uD30C\uC2F1\uB429\uB2C8\uB2E4." };
815
854
  }
816
855
  });
817
856
 
@@ -826,10 +865,11 @@ var schemaTypeRecognizedRule = defineRule({
826
865
  effort: "low",
827
866
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdschema-type-recognized",
828
867
  title: "Schema.org @type is a recognised kind",
868
+ title_ko: "Schema.org @type \uC778\uC2DD \uAC00\uB2A5 \uC5EC\uBD80",
829
869
  description: "AI engines match pages against well-known types (Article, Product, FAQPage...). Obscure types weaken the signal.",
830
870
  run(ctx) {
831
871
  if (ctx.jsonLd.length === 0) {
832
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
872
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
833
873
  }
834
874
  const nodes = flattenJsonLd(ctx.jsonLd);
835
875
  const recognized = /* @__PURE__ */ new Set();
@@ -845,6 +885,7 @@ var schemaTypeRecognizedRule = defineRule({
845
885
  status: "pass",
846
886
  score: 1,
847
887
  rationale: `Recognised: ${[...recognized].join(", ")}.`,
888
+ rationale_ko: `\uC778\uC2DD\uB41C \uD0C0\uC785: ${[...recognized].join(", ")}.`,
848
889
  evidence: { recognized: [...recognized], all: [...seenTypes] }
849
890
  };
850
891
  }
@@ -852,6 +893,7 @@ var schemaTypeRecognizedRule = defineRule({
852
893
  status: "warn",
853
894
  score: 0.3,
854
895
  rationale: `No recognised schema.org types. Saw: ${[...seenTypes].join(", ") || "(none)"}.`,
896
+ rationale_ko: `\uC778\uC2DD \uAC00\uB2A5\uD55C schema.org \uD0C0\uC785\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. \uBC1C\uACAC\uB41C \uD0C0\uC785: ${[...seenTypes].join(", ") || "(\uC5C6\uC74C)"}.`,
855
897
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
856
898
  };
857
899
  }
@@ -868,10 +910,11 @@ var requiredFieldsRule = defineRule({
868
910
  effort: "medium",
869
911
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdrequired-fields",
870
912
  title: "Required fields for recognised types are set",
913
+ title_ko: "\uC778\uC2DD\uB41C \uD0C0\uC785\uC758 \uD544\uC218 \uD544\uB4DC \uCDA9\uC871 \uC5EC\uBD80",
871
914
  description: "Article needs headline/author/datePublished, FAQPage needs mainEntity, Product needs offers, etc.",
872
915
  run(ctx) {
873
916
  if (ctx.jsonLd.length === 0) {
874
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
917
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
875
918
  }
876
919
  const nodes = flattenJsonLd(ctx.jsonLd);
877
920
  const missing = [];
@@ -890,14 +933,16 @@ var requiredFieldsRule = defineRule({
890
933
  return {
891
934
  status: "skip",
892
935
  score: 0,
893
- rationale: "No types with known required fields were found."
936
+ rationale: "No types with known required fields were found.",
937
+ rationale_ko: "\uD544\uC218 \uD544\uB4DC\uAC00 \uC815\uC758\uB41C \uD0C0\uC785\uC774 \uC5C6\uC2B5\uB2C8\uB2E4."
894
938
  };
895
939
  }
896
940
  if (missing.length === 0) {
897
941
  return {
898
942
  status: "pass",
899
943
  score: 1,
900
- rationale: `Required fields set on ${checked.length} node(s).`
944
+ rationale: `Required fields set on ${checked.length} node(s).`,
945
+ rationale_ko: `${checked.length}\uAC1C \uB178\uB4DC\uC758 \uD544\uC218 \uD544\uB4DC\uAC00 \uBAA8\uB450 \uCDA9\uC871\uB429\uB2C8\uB2E4.`
901
946
  };
902
947
  }
903
948
  const msg = missing.map((m) => `${m.type}.${m.field}`).join(", ");
@@ -905,6 +950,7 @@ var requiredFieldsRule = defineRule({
905
950
  status: "fail",
906
951
  score: Math.max(0, 1 - missing.length / (checked.length * 2)),
907
952
  rationale: `Missing required fields: ${msg}.`,
953
+ rationale_ko: `\uB204\uB77D\uB41C \uD544\uC218 \uD544\uB4DC: ${msg}.`,
908
954
  evidence: missing,
909
955
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
910
956
  };
@@ -922,10 +968,11 @@ var microdataFallbackRule = defineRule({
922
968
  effort: "medium",
923
969
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdmicrodata-fallback",
924
970
  title: "Microdata or RDFa fallback when JSON-LD is missing",
971
+ title_ko: "JSON-LD \uC5C6\uC744 \uB54C Microdata/RDFa \uB300\uCCB4 \uC5EC\uBD80",
925
972
  description: "If JSON-LD is absent, inline microdata (itemscope/itemtype) or RDFa still gives some structured signal.",
926
973
  run(ctx) {
927
974
  if (ctx.jsonLd.length > 0) {
928
- return { status: "skip", score: 0, rationale: "JSON-LD is present; fallback not needed." };
975
+ return { status: "skip", score: 0, rationale: "JSON-LD is present; fallback not needed.", rationale_ko: "JSON-LD\uAC00 \uC788\uC73C\uBBC0\uB85C \uB300\uCCB4 \uC218\uB2E8\uC774 \uD544\uC694\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4." };
929
976
  }
930
977
  const microdata = ctx.$("[itemscope][itemtype]").length;
931
978
  const rdfa = ctx.$("[typeof][vocab], [typeof][property]").length;
@@ -933,13 +980,15 @@ var microdataFallbackRule = defineRule({
933
980
  return {
934
981
  status: "pass",
935
982
  score: 1,
936
- rationale: `Found ${microdata} microdata and ${rdfa} RDFa nodes.`
983
+ rationale: `Found ${microdata} microdata and ${rdfa} RDFa nodes.`,
984
+ rationale_ko: `Microdata ${microdata}\uAC1C, RDFa ${rdfa}\uAC1C \uBC1C\uACAC\uB429\uB2C8\uB2E4.`
937
985
  };
938
986
  }
939
987
  return {
940
988
  status: "fail",
941
989
  score: 0,
942
990
  rationale: "No structured data at all (no JSON-LD, no microdata, no RDFa).",
991
+ rationale_ko: "\uAD6C\uC870\uD654 \uB370\uC774\uD130\uAC00 \uC804\uD600 \uC5C6\uC2B5\uB2C8\uB2E4 (JSON-LD, Microdata, RDFa \uBAA8\uB450 \uC5C6\uC74C).",
943
992
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
944
993
  };
945
994
  }
@@ -957,10 +1006,11 @@ var noDuplicateTypesRule = defineRule({
957
1006
  effort: "low",
958
1007
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdno-duplicate-types",
959
1008
  title: "No conflicting duplicate @types",
1009
+ title_ko: "@type \uC911\uBCF5 \uCDA9\uB3CC \uC5C6\uC74C",
960
1010
  description: "Multiple competing entities of the same primary type (e.g. two Articles) confuse the engine about which one represents the page.",
961
1011
  run(ctx) {
962
1012
  if (ctx.jsonLd.length === 0) {
963
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
1013
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
964
1014
  }
965
1015
  const counts = /* @__PURE__ */ new Map();
966
1016
  for (const node of flattenJsonLd(ctx.jsonLd)) {
@@ -970,12 +1020,13 @@ var noDuplicateTypesRule = defineRule({
970
1020
  }
971
1021
  const dupes = [...counts.entries()].filter(([, n]) => n > 1);
972
1022
  if (dupes.length === 0) {
973
- return { status: "pass", score: 1, rationale: "No duplicate primary types." };
1023
+ return { status: "pass", score: 1, rationale: "No duplicate primary types.", rationale_ko: "\uC911\uBCF5\uB41C \uAE30\uBCF8 \uD0C0\uC785\uC774 \uC5C6\uC2B5\uB2C8\uB2E4." };
974
1024
  }
975
1025
  return {
976
1026
  status: "warn",
977
1027
  score: 0.4,
978
1028
  rationale: `Duplicate primary types: ${dupes.map(([t, n]) => `${t}\xD7${n}`).join(", ")}.`,
1029
+ rationale_ko: `\uC911\uBCF5\uB41C \uAE30\uBCF8 \uD0C0\uC785: ${dupes.map(([t, n]) => `${t}\xD7${n}`).join(", ")}.`,
979
1030
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
980
1031
  };
981
1032
  }
@@ -1023,10 +1074,11 @@ var sameAsEntityRule = defineRule({
1023
1074
  effort: "medium",
1024
1075
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdsameas-entity",
1025
1076
  title: "Entity nodes link the knowledge graph via sameAs",
1077
+ title_ko: "sameAs\uB85C \uC9C0\uC2DD \uADF8\uB798\uD504 \uC5F0\uACB0 \uC5EC\uBD80",
1026
1078
  description: "Organization/Person nodes should declare a sameAs[] array linking to Wikipedia/Wikidata/LinkedIn so AI engines can resolve the entity in their knowledge graph (E-E-A-T signal).",
1027
1079
  run(ctx) {
1028
1080
  if (ctx.jsonLd.length === 0) {
1029
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
1081
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
1030
1082
  }
1031
1083
  const nodes = flattenJsonLd(ctx.jsonLd);
1032
1084
  const entities = nodes.filter((n) => getTypes(n).some((t) => ENTITY_TYPES.includes(t)));
@@ -1034,7 +1086,8 @@ var sameAsEntityRule = defineRule({
1034
1086
  return {
1035
1087
  status: "skip",
1036
1088
  score: 0,
1037
- rationale: "No Organization/Person/LocalBusiness/Brand entity to link."
1089
+ rationale: "No Organization/Person/LocalBusiness/Brand entity to link.",
1090
+ rationale_ko: "\uC5F0\uACB0\uD560 Organization/Person/LocalBusiness/Brand \uC5D4\uD2F0\uD2F0\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4."
1038
1091
  };
1039
1092
  }
1040
1093
  let bestScore = 0;
@@ -1058,6 +1111,7 @@ var sameAsEntityRule = defineRule({
1058
1111
  status: "pass",
1059
1112
  score: 1,
1060
1113
  rationale: `Entity links ${bestEvidence.trusted} trusted knowledge-graph hosts via sameAs.`,
1114
+ rationale_ko: `\uC5D4\uD2F0\uD2F0\uAC00 sameAs\uB85C \uC2E0\uB8B0\uD560 \uC218 \uC788\uB294 \uC9C0\uC2DD \uADF8\uB798\uD504 \uC0AC\uC774\uD2B8 ${bestEvidence.trusted}\uAC1C\uC5D0 \uC5F0\uACB0\uB418\uC5B4 \uC788\uC2B5\uB2C8\uB2E4.`,
1061
1115
  evidence: bestEvidence
1062
1116
  };
1063
1117
  }
@@ -1066,6 +1120,7 @@ var sameAsEntityRule = defineRule({
1066
1120
  status: "pass",
1067
1121
  score: bestScore,
1068
1122
  rationale: `Entity has 1 trusted sameAs link. Add Wikipedia/Wikidata for stronger E-E-A-T.`,
1123
+ rationale_ko: "\uC2E0\uB8B0\uD560 \uC218 \uC788\uB294 sameAs \uB9C1\uD06C\uAC00 1\uAC1C \uC788\uC2B5\uB2C8\uB2E4. E-E-A-T \uAC15\uD654\uB97C \uC704\uD574 Wikipedia/Wikidata\uB97C \uCD94\uAC00\uD558\uC138\uC694.",
1069
1124
  evidence: bestEvidence,
1070
1125
  estimatedImpact: 1
1071
1126
  };
@@ -1075,6 +1130,7 @@ var sameAsEntityRule = defineRule({
1075
1130
  status: "warn",
1076
1131
  score: bestScore,
1077
1132
  rationale: "Entity declares sameAs but no trusted knowledge-graph hosts (Wikipedia/Wikidata/LinkedIn).",
1133
+ rationale_ko: "sameAs\uAC00 \uC120\uC5B8\uB418\uC5B4 \uC788\uC9C0\uB9CC \uC2E0\uB8B0\uD560 \uC218 \uC788\uB294 \uC9C0\uC2DD \uADF8\uB798\uD504 \uD638\uC2A4\uD2B8(Wikipedia/Wikidata/LinkedIn)\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.",
1078
1134
  evidence: bestEvidence,
1079
1135
  fixHint: "Add Wikipedia/Wikidata/LinkedIn URLs to your Organization sameAs[].",
1080
1136
  estimatedImpact: 2
@@ -1084,6 +1140,7 @@ var sameAsEntityRule = defineRule({
1084
1140
  status: "warn",
1085
1141
  score: 0,
1086
1142
  rationale: `${entities.length} entity node(s) found but none declare sameAs links.`,
1143
+ rationale_ko: `\uC5D4\uD2F0\uD2F0 \uB178\uB4DC\uAC00 ${entities.length}\uAC1C \uC788\uC9C0\uB9CC sameAs \uB9C1\uD06C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.`,
1087
1144
  fixHint: 'Add sameAs:["https://en.wikipedia.org/wiki/...", "https://www.linkedin.com/company/..."] to your Organization JSON-LD.',
1088
1145
  estimatedImpact: 3
1089
1146
  };
@@ -1115,15 +1172,16 @@ var breadcrumbValidRule = defineRule({
1115
1172
  effort: "medium",
1116
1173
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdbreadcrumb-valid",
1117
1174
  title: "BreadcrumbList items declare position, name, and item",
1175
+ title_ko: "BreadcrumbList \uD56D\uBAA9\uC758 \uD544\uC218 \uD544\uB4DC \uCDA9\uC871 \uC5EC\uBD80",
1118
1176
  description: "When BreadcrumbList JSON-LD is present, every itemListElement should set position (1-indexed), name, and item (URL) \u2014 otherwise AI engines cannot reconstruct the path.",
1119
1177
  run(ctx) {
1120
1178
  if (ctx.jsonLd.length === 0) {
1121
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
1179
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
1122
1180
  }
1123
1181
  const nodes = flattenJsonLd(ctx.jsonLd);
1124
1182
  const breadcrumbs = nodes.filter((n) => getTypes(n).includes("BreadcrumbList"));
1125
1183
  if (breadcrumbs.length === 0) {
1126
- return { status: "skip", score: 0, rationale: "No BreadcrumbList present." };
1184
+ return { status: "skip", score: 0, rationale: "No BreadcrumbList present.", rationale_ko: "BreadcrumbList\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
1127
1185
  }
1128
1186
  const allIssues = [];
1129
1187
  let totalItems = 0;
@@ -1136,7 +1194,8 @@ var breadcrumbValidRule = defineRule({
1136
1194
  return {
1137
1195
  status: "pass",
1138
1196
  score: 1,
1139
- rationale: `BreadcrumbList(s) valid (${totalItems} items).`
1197
+ rationale: `BreadcrumbList(s) valid (${totalItems} items).`,
1198
+ rationale_ko: `BreadcrumbList\uAC00 \uC720\uD6A8\uD569\uB2C8\uB2E4 (\uD56D\uBAA9 ${totalItems}\uAC1C).`
1140
1199
  };
1141
1200
  }
1142
1201
  const fatalCount = allIssues.length;
@@ -1146,6 +1205,7 @@ var breadcrumbValidRule = defineRule({
1146
1205
  status: score < 0.5 ? "fail" : "warn",
1147
1206
  score,
1148
1207
  rationale: `${fatalCount} breadcrumb item(s) missing required fields.`,
1208
+ rationale_ko: `breadcrumb \uD56D\uBAA9 ${fatalCount}\uAC1C\uC5D0 \uD544\uC218 \uD544\uB4DC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.`,
1149
1209
  evidence: allIssues.slice(0, 5),
1150
1210
  fixHint: 'Each itemListElement needs { "@type": "ListItem", position: N, name, item }.',
1151
1211
  estimatedImpact: Math.round(2 * (1 - score))
@@ -1176,6 +1236,7 @@ var titleRule = defineRule({
1176
1236
  effort: "low",
1177
1237
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cittitle",
1178
1238
  title: "<title> is set with a reasonable length",
1239
+ title_ko: "<title> \uD0DC\uADF8 \uC801\uC815 \uAE38\uC774 \uC124\uC815 \uC5EC\uBD80",
1179
1240
  description: "The document title is the single most-cited piece of text and should be 10\u201370 characters.",
1180
1241
  run(ctx) {
1181
1242
  const title = ctx.$("head > title").first().text().trim();
@@ -1184,6 +1245,7 @@ var titleRule = defineRule({
1184
1245
  status: "fail",
1185
1246
  score: 0,
1186
1247
  rationale: "Page has no <title>.",
1248
+ rationale_ko: "\uD398\uC774\uC9C0\uC5D0 <title>\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.",
1187
1249
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1188
1250
  };
1189
1251
  }
@@ -1191,17 +1253,19 @@ var titleRule = defineRule({
1191
1253
  return {
1192
1254
  status: "warn",
1193
1255
  score: 0.4,
1194
- rationale: `Title is only ${title.length} chars; consider a more descriptive one.`
1256
+ rationale: `Title is only ${title.length} chars; consider a more descriptive one.`,
1257
+ rationale_ko: `\uC81C\uBAA9\uC774 ${title.length}\uC790\uBC16\uC5D0 \uC548 \uB429\uB2C8\uB2E4. \uB354 \uAD6C\uCCB4\uC801\uC73C\uB85C \uC791\uC131\uD558\uC138\uC694.`
1195
1258
  };
1196
1259
  }
1197
1260
  if (title.length > 70) {
1198
1261
  return {
1199
1262
  status: "warn",
1200
1263
  score: 0.6,
1201
- rationale: `Title is ${title.length} chars; search UIs commonly truncate after ~70.`
1264
+ rationale: `Title is ${title.length} chars; search UIs commonly truncate after ~70.`,
1265
+ rationale_ko: `\uC81C\uBAA9\uC774 ${title.length}\uC790\uC785\uB2C8\uB2E4. \uAC80\uC0C9 UI\uC5D0\uC11C 70\uC790 \uC774\uD6C4\uB294 \uC798\uB9B4 \uC218 \uC788\uC2B5\uB2C8\uB2E4.`
1202
1266
  };
1203
1267
  }
1204
- return { status: "pass", score: 1, rationale: `Title length ${title.length} is within range.` };
1268
+ return { status: "pass", score: 1, rationale: `Title length ${title.length} is within range.`, rationale_ko: `\uC81C\uBAA9 \uAE38\uC774 ${title.length}\uC790\uB85C \uC801\uC808\uD569\uB2C8\uB2E4.` };
1205
1269
  }
1206
1270
  });
1207
1271
 
@@ -1216,6 +1280,7 @@ var metaDescriptionRule = defineRule({
1216
1280
  effort: "low",
1217
1281
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citmeta-description",
1218
1282
  title: "meta description is set (50\u2013160 chars)",
1283
+ title_ko: "meta description \uC124\uC815 \uC5EC\uBD80 (50\u2013160\uC790)",
1219
1284
  description: "AI snippets often quote the meta description verbatim; aim for 50\u2013160 chars.",
1220
1285
  run(ctx) {
1221
1286
  const desc = ctx.$('head meta[name="description"]').attr("content")?.trim() ?? "";
@@ -1224,16 +1289,17 @@ var metaDescriptionRule = defineRule({
1224
1289
  status: "warn",
1225
1290
  score: 0,
1226
1291
  rationale: "No meta description set.",
1292
+ rationale_ko: "meta description\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.",
1227
1293
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1228
1294
  };
1229
1295
  }
1230
1296
  if (desc.length < 50) {
1231
- return { status: "warn", score: 0.5, rationale: `Only ${desc.length} chars; aim for 50+.` };
1297
+ return { status: "warn", score: 0.5, rationale: `Only ${desc.length} chars; aim for 50+.`, rationale_ko: `${desc.length}\uC790\uBC16\uC5D0 \uC548 \uB429\uB2C8\uB2E4. 50\uC790 \uC774\uC0C1\uC744 \uBAA9\uD45C\uB85C \uD558\uC138\uC694.` };
1232
1298
  }
1233
1299
  if (desc.length > 160) {
1234
- return { status: "warn", score: 0.7, rationale: `${desc.length} chars; may be truncated after 160.` };
1300
+ return { status: "warn", score: 0.7, rationale: `${desc.length} chars; may be truncated after 160.`, rationale_ko: `${desc.length}\uC790\uC785\uB2C8\uB2E4. 160\uC790 \uC774\uD6C4\uB294 \uC798\uB9B4 \uC218 \uC788\uC2B5\uB2C8\uB2E4.` };
1235
1301
  }
1236
- return { status: "pass", score: 1, rationale: `Description length ${desc.length} is within range.` };
1302
+ return { status: "pass", score: 1, rationale: `Description length ${desc.length} is within range.`, rationale_ko: `\uC124\uBA85 \uAE38\uC774 ${desc.length}\uC790\uB85C \uC801\uC808\uD569\uB2C8\uB2E4.` };
1237
1303
  }
1238
1304
  });
1239
1305
 
@@ -1248,6 +1314,7 @@ var canonicalRule = defineRule({
1248
1314
  effort: "low",
1249
1315
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citcanonical",
1250
1316
  title: "Canonical URL is declared",
1317
+ title_ko: "Canonical URL \uC120\uC5B8 \uC5EC\uBD80",
1251
1318
  description: 'rel="canonical" tells crawlers which URL is the source of truth, preventing duplicate-citation confusion.',
1252
1319
  run(ctx) {
1253
1320
  const href = ctx.$('head link[rel="canonical"]').attr("href")?.trim();
@@ -1256,14 +1323,15 @@ var canonicalRule = defineRule({
1256
1323
  status: "warn",
1257
1324
  score: 0,
1258
1325
  rationale: 'No <link rel="canonical"> found.',
1326
+ rationale_ko: '<link rel="canonical">\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.',
1259
1327
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1260
1328
  };
1261
1329
  }
1262
1330
  try {
1263
1331
  const abs = new URL(href, ctx.finalUrl).toString();
1264
- return { status: "pass", score: 1, rationale: `Canonical URL: ${abs}.` };
1332
+ return { status: "pass", score: 1, rationale: `Canonical URL: ${abs}.`, rationale_ko: `Canonical URL: ${abs}.` };
1265
1333
  } catch {
1266
- return { status: "fail", score: 0, rationale: `Canonical href is not a valid URL: ${href}` };
1334
+ return { status: "fail", score: 0, rationale: `Canonical href is not a valid URL: ${href}`, rationale_ko: `Canonical href\uAC00 \uC720\uD6A8\uD55C URL\uC774 \uC544\uB2D9\uB2C8\uB2E4: ${href}` };
1267
1335
  }
1268
1336
  }
1269
1337
  });
@@ -1280,6 +1348,7 @@ var ogTagsRule = defineRule({
1280
1348
  effort: "low",
1281
1349
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citog-tags",
1282
1350
  title: "Open Graph tags are set",
1351
+ title_ko: "Open Graph \uD0DC\uADF8 \uC124\uC815 \uC5EC\uBD80",
1283
1352
  description: "og:title/type/url/image power rich previews on AI chat, social, and messaging.",
1284
1353
  run(ctx) {
1285
1354
  const missing = [];
@@ -1288,13 +1357,14 @@ var ogTagsRule = defineRule({
1288
1357
  if (!val) missing.push(prop);
1289
1358
  }
1290
1359
  if (missing.length === 0) {
1291
- return { status: "pass", score: 1, rationale: "All required OG tags present." };
1360
+ return { status: "pass", score: 1, rationale: "All required OG tags present.", rationale_ko: "\uD544\uC218 OG \uD0DC\uADF8\uAC00 \uBAA8\uB450 \uC788\uC2B5\uB2C8\uB2E4." };
1292
1361
  }
1293
1362
  const ratio = 1 - missing.length / REQUIRED.length;
1294
1363
  return {
1295
1364
  status: missing.length === REQUIRED.length ? "fail" : "warn",
1296
1365
  score: ratio,
1297
1366
  rationale: `Missing: ${missing.join(", ")}.`,
1367
+ rationale_ko: `\uB204\uB77D\uB41C \uD0DC\uADF8: ${missing.join(", ")}.`,
1298
1368
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1299
1369
  };
1300
1370
  }
@@ -1311,20 +1381,22 @@ var twitterCardRule = defineRule({
1311
1381
  effort: "low",
1312
1382
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cittwitter-card",
1313
1383
  title: "Twitter Card metadata is set",
1384
+ title_ko: "Twitter Card \uBA54\uD0C0\uB370\uC774\uD130 \uC124\uC815 \uC5EC\uBD80",
1314
1385
  description: "twitter:card + twitter:title give better previews on X/Twitter and some AI surfaces that reuse the tags.",
1315
1386
  run(ctx) {
1316
1387
  const card = ctx.$('head meta[name="twitter:card"]').attr("content")?.trim();
1317
1388
  const title = ctx.$('head meta[name="twitter:title"]').attr("content")?.trim();
1318
1389
  if (card && title) {
1319
- return { status: "pass", score: 1, rationale: `Card type: ${card}.` };
1390
+ return { status: "pass", score: 1, rationale: `Card type: ${card}.`, rationale_ko: `\uCE74\uB4DC \uC720\uD615: ${card}.` };
1320
1391
  }
1321
1392
  if (card || title) {
1322
- return { status: "warn", score: 0.5, rationale: "Partial twitter:* metadata; add the missing tag." };
1393
+ return { status: "warn", score: 0.5, rationale: "Partial twitter:* metadata; add the missing tag.", rationale_ko: "twitter:* \uBA54\uD0C0\uB370\uC774\uD130\uAC00 \uC77C\uBD80\uB9CC \uC788\uC2B5\uB2C8\uB2E4. \uB204\uB77D\uB41C \uD0DC\uADF8\uB97C \uCD94\uAC00\uD558\uC138\uC694." };
1323
1394
  }
1324
1395
  return {
1325
1396
  status: "warn",
1326
1397
  score: 0,
1327
1398
  rationale: "No twitter:card metadata.",
1399
+ rationale_ko: "twitter:card \uBA54\uD0C0\uB370\uC774\uD130\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.",
1328
1400
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1329
1401
  };
1330
1402
  }
@@ -1341,6 +1413,7 @@ var langAttrRule = defineRule({
1341
1413
  effort: "low",
1342
1414
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citlang-attr",
1343
1415
  title: "<html lang> is set",
1416
+ title_ko: "<html lang> \uC18D\uC131 \uC124\uC815 \uC5EC\uBD80",
1344
1417
  description: "A lang attribute helps AI engines route the page to the right-language search surface (and helps screen readers).",
1345
1418
  run(ctx) {
1346
1419
  const lang = ctx.$("html").attr("lang")?.trim();
@@ -1349,10 +1422,11 @@ var langAttrRule = defineRule({
1349
1422
  status: "warn",
1350
1423
  score: 0,
1351
1424
  rationale: "No lang attribute on <html>.",
1425
+ rationale_ko: "<html>\uC5D0 lang \uC18D\uC131\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.",
1352
1426
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1353
1427
  };
1354
1428
  }
1355
- return { status: "pass", score: 1, rationale: `lang="${lang}".` };
1429
+ return { status: "pass", score: 1, rationale: `lang="${lang}".`, rationale_ko: `lang="${lang}".` };
1356
1430
  }
1357
1431
  });
1358
1432
 
@@ -1367,25 +1441,27 @@ var authorVisibleRule = defineRule({
1367
1441
  effort: "medium",
1368
1442
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citauthor-visible",
1369
1443
  title: "Author is declared",
1444
+ title_ko: "\uC791\uC131\uC790 \uC815\uBCF4 \uC120\uC5B8 \uC5EC\uBD80",
1370
1445
  description: "AI engines prefer citing content with an identifiable author; expose one via JSON-LD, meta[name=author], rel=author, or a .author class.",
1371
1446
  run(ctx) {
1372
1447
  for (const node of flattenJsonLd(ctx.jsonLd)) {
1373
1448
  if (hasField(node, "author")) {
1374
- return { status: "pass", score: 1, rationale: "Author found in JSON-LD." };
1449
+ return { status: "pass", score: 1, rationale: "Author found in JSON-LD.", rationale_ko: "JSON-LD\uC5D0\uC11C \uC791\uC131\uC790 \uC815\uBCF4\uB97C \uCC3E\uC558\uC2B5\uB2C8\uB2E4." };
1375
1450
  }
1376
1451
  }
1377
1452
  const metaAuthor = ctx.$('head meta[name="author"]').attr("content")?.trim();
1378
- if (metaAuthor) return { status: "pass", score: 1, rationale: `meta[name=author] = "${metaAuthor}".` };
1453
+ if (metaAuthor) return { status: "pass", score: 1, rationale: `meta[name=author] = "${metaAuthor}".`, rationale_ko: `meta[name=author] = "${metaAuthor}".` };
1379
1454
  if (ctx.$('[rel="author"]').length > 0) {
1380
- return { status: "pass", score: 1, rationale: 'rel="author" link found.' };
1455
+ return { status: "pass", score: 1, rationale: 'rel="author" link found.', rationale_ko: 'rel="author" \uB9C1\uD06C\uB97C \uCC3E\uC558\uC2B5\uB2C8\uB2E4.' };
1381
1456
  }
1382
1457
  if (ctx.$('.author, [class*="author"], [itemprop="author"]').length > 0) {
1383
- return { status: "pass", score: 0.8, rationale: "Author-ish DOM selector found (weaker signal)." };
1458
+ return { status: "pass", score: 0.8, rationale: "Author-ish DOM selector found (weaker signal).", rationale_ko: "\uC791\uC131\uC790 \uAD00\uB828 DOM \uC120\uD0DD\uC790\uAC00 \uC788\uC2B5\uB2C8\uB2E4 (\uC2E0\uD638 \uAC15\uB3C4 \uB0AE\uC74C)." };
1384
1459
  }
1385
1460
  return {
1386
1461
  status: "warn",
1387
1462
  score: 0,
1388
1463
  rationale: "No author signal found (JSON-LD, meta, rel, or .author).",
1464
+ rationale_ko: "\uC791\uC131\uC790 \uC815\uBCF4\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4 (JSON-LD, meta, rel, .author \uBAA8\uB450 \uC5C6\uC74C).",
1389
1465
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1390
1466
  };
1391
1467
  }
@@ -1402,25 +1478,27 @@ var datesRule = defineRule({
1402
1478
  effort: "low",
1403
1479
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citdates",
1404
1480
  title: "Publish / modified date is present",
1481
+ title_ko: "\uBC1C\uD589\uC77C / \uC218\uC815\uC77C \uC874\uC7AC \uC5EC\uBD80",
1405
1482
  description: "AI engines rank recent pages higher; expose datePublished via JSON-LD, <time datetime>, or article:published_time meta.",
1406
1483
  run(ctx) {
1407
1484
  for (const node of flattenJsonLd(ctx.jsonLd)) {
1408
1485
  if (hasField(node, "datePublished")) {
1409
- return { status: "pass", score: 1, rationale: "datePublished found in JSON-LD." };
1486
+ return { status: "pass", score: 1, rationale: "datePublished found in JSON-LD.", rationale_ko: "JSON-LD\uC5D0\uC11C datePublished\uB97C \uCC3E\uC558\uC2B5\uB2C8\uB2E4." };
1410
1487
  }
1411
1488
  }
1412
1489
  const articleTime = ctx.$('head meta[property="article:published_time"]').attr("content")?.trim();
1413
1490
  if (articleTime) {
1414
- return { status: "pass", score: 1, rationale: `article:published_time = ${articleTime}.` };
1491
+ return { status: "pass", score: 1, rationale: `article:published_time = ${articleTime}.`, rationale_ko: `article:published_time = ${articleTime}.` };
1415
1492
  }
1416
1493
  const timeEl = ctx.$("time[datetime]").first().attr("datetime")?.trim();
1417
1494
  if (timeEl) {
1418
- return { status: "pass", score: 0.8, rationale: `<time datetime="${timeEl}"> found.` };
1495
+ return { status: "pass", score: 0.8, rationale: `<time datetime="${timeEl}"> found.`, rationale_ko: `<time datetime="${timeEl}">\uB97C \uCC3E\uC558\uC2B5\uB2C8\uB2E4.` };
1419
1496
  }
1420
1497
  return {
1421
1498
  status: "warn",
1422
1499
  score: 0,
1423
1500
  rationale: "No publish date found (JSON-LD, meta article:published_time, or <time datetime>).",
1501
+ rationale_ko: "\uBC1C\uD589\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4 (JSON-LD, meta article:published_time, <time datetime> \uBAA8\uB450 \uC5C6\uC74C).",
1424
1502
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1425
1503
  };
1426
1504
  }
@@ -1448,6 +1526,7 @@ var contentFreshnessRule = defineRule({
1448
1526
  effort: "low",
1449
1527
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citcontent-freshness",
1450
1528
  title: "Article content is fresh (dateModified within 1 year)",
1529
+ title_ko: "\uCF58\uD150\uCE20 \uCD5C\uC2E0\uC131 (dateModified 1\uB144 \uC774\uB0B4)",
1451
1530
  description: "AI engines down-rank stale content. Surface a recent dateModified (\u2264365 days) on Article-like pages so retrieval rankings stay strong.",
1452
1531
  run(ctx) {
1453
1532
  const nodes = flattenJsonLd(ctx.jsonLd);
@@ -1456,7 +1535,8 @@ var contentFreshnessRule = defineRule({
1456
1535
  return {
1457
1536
  status: "skip",
1458
1537
  score: 0,
1459
- rationale: "No Article/BlogPosting/NewsArticle JSON-LD; freshness signal not applicable."
1538
+ rationale: "No Article/BlogPosting/NewsArticle JSON-LD; freshness signal not applicable.",
1539
+ rationale_ko: "Article/BlogPosting/NewsArticle JSON-LD\uAC00 \uC5C6\uC5B4 \uCD5C\uC2E0\uC131 \uC2E0\uD638\uB97C \uD655\uC778\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4."
1460
1540
  };
1461
1541
  }
1462
1542
  let bestMs = null;
@@ -1478,6 +1558,7 @@ var contentFreshnessRule = defineRule({
1478
1558
  status: "warn",
1479
1559
  score: 0,
1480
1560
  rationale: "Article has no parseable dateModified or datePublished.",
1561
+ rationale_ko: "Article JSON-LD\uC5D0 \uD30C\uC2F1 \uAC00\uB2A5\uD55C dateModified \uB610\uB294 datePublished\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.",
1481
1562
  fixHint: "Add ISO-8601 dateModified and datePublished to your Article JSON-LD.",
1482
1563
  estimatedImpact: 3
1483
1564
  };
@@ -1488,6 +1569,7 @@ var contentFreshnessRule = defineRule({
1488
1569
  status: "pass",
1489
1570
  score: 1,
1490
1571
  rationale: `${usedField} within the last year (~${ageDays} day${ageDays === 1 ? "" : "s"} ago).`,
1572
+ rationale_ko: `${usedField}\uC774 1\uB144 \uC774\uB0B4\uC785\uB2C8\uB2E4 (\uC57D ${ageDays}\uC77C \uC804).`,
1491
1573
  evidence: { ageDays, field: usedField }
1492
1574
  };
1493
1575
  }
@@ -1496,6 +1578,7 @@ var contentFreshnessRule = defineRule({
1496
1578
  status: "warn",
1497
1579
  score: 0.6,
1498
1580
  rationale: `${usedField} is ${ageDays} days old. Refresh within a year for best AI ranking.`,
1581
+ rationale_ko: `${usedField}\uC774 ${ageDays}\uC77C \uB410\uC2B5\uB2C8\uB2E4. AI \uC21C\uC704\uB97C \uC720\uC9C0\uD558\uB824\uBA74 1\uB144 \uC774\uB0B4\uB85C \uAC31\uC2E0\uD558\uC138\uC694.`,
1499
1582
  evidence: { ageDays, field: usedField },
1500
1583
  estimatedImpact: 2
1501
1584
  };
@@ -1504,6 +1587,7 @@ var contentFreshnessRule = defineRule({
1504
1587
  status: "warn",
1505
1588
  score: 0.2,
1506
1589
  rationale: `${usedField} is ${ageDays} days old (>2 years). AI engines treat this as stale.`,
1590
+ rationale_ko: `${usedField}\uC774 ${ageDays}\uC77C \uB410\uC2B5\uB2C8\uB2E4 (2\uB144 \uCD08\uACFC). AI \uC5D4\uC9C4\uC774 \uC624\uB798\uB41C \uCF58\uD150\uCE20\uB85C \uAC04\uC8FC\uD569\uB2C8\uB2E4.`,
1507
1591
  evidence: { ageDays, field: usedField },
1508
1592
  fixHint: "Update content and bump dateModified to today's date.",
1509
1593
  estimatedImpact: 3
@@ -1535,22 +1619,25 @@ var singleH1Rule = defineRule({
1535
1619
  effort: "low",
1536
1620
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntsingle-h1",
1537
1621
  title: "Exactly one <h1>",
1622
+ title_ko: "<h1> \uD0DC\uADF8 1\uAC1C \uC5EC\uBD80",
1538
1623
  description: "A single H1 tells AI engines the primary topic of the page without ambiguity.",
1539
1624
  run(ctx) {
1540
1625
  const n = ctx.$("h1").length;
1541
- if (n === 1) return { status: "pass", score: 1, rationale: "Exactly one <h1>." };
1626
+ if (n === 1) return { status: "pass", score: 1, rationale: "Exactly one <h1>.", rationale_ko: "<h1>\uC774 \uC815\uD655\uD788 1\uAC1C\uC785\uB2C8\uB2E4." };
1542
1627
  if (n === 0) {
1543
1628
  return {
1544
1629
  status: "fail",
1545
1630
  score: 0,
1546
1631
  rationale: "No <h1> on the page.",
1632
+ rationale_ko: "\uD398\uC774\uC9C0\uC5D0 <h1>\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.",
1547
1633
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules/cnt.single-h1.md"
1548
1634
  };
1549
1635
  }
1550
1636
  return {
1551
1637
  status: "warn",
1552
1638
  score: Math.max(0.3, 1 / n),
1553
- rationale: `Found ${n} <h1> tags; prefer one primary heading.`
1639
+ rationale: `Found ${n} <h1> tags; prefer one primary heading.`,
1640
+ rationale_ko: `<h1>\uC774 ${n}\uAC1C \uC788\uC2B5\uB2C8\uB2E4. \uB300\uD45C \uC81C\uBAA9 1\uAC1C\uB9CC \uC0AC\uC6A9\uD558\uC138\uC694.`
1554
1641
  };
1555
1642
  }
1556
1643
  });
@@ -1566,6 +1653,7 @@ var headingHierarchyRule = defineRule({
1566
1653
  effort: "medium",
1567
1654
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntheading-hierarchy",
1568
1655
  title: "Heading levels do not skip",
1656
+ title_ko: "\uC81C\uBAA9 \uB2E8\uACC4 \uC21C\uC11C \uC900\uC218 \uC5EC\uBD80",
1569
1657
  description: "Going from H2 directly to H4 breaks the outline AI engines use to segment content.",
1570
1658
  run(ctx) {
1571
1659
  const levels = [];
@@ -1575,7 +1663,7 @@ var headingHierarchyRule = defineRule({
1575
1663
  if (m?.[1]) levels.push(parseInt(m[1], 10));
1576
1664
  });
1577
1665
  if (levels.length === 0) {
1578
- return { status: "skip", score: 0, rationale: "No headings found." };
1666
+ return { status: "skip", score: 0, rationale: "No headings found.", rationale_ko: "\uC81C\uBAA9 \uD0DC\uADF8\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
1579
1667
  }
1580
1668
  const skips = [];
1581
1669
  for (let i = 1; i < levels.length; i++) {
@@ -1584,12 +1672,13 @@ var headingHierarchyRule = defineRule({
1584
1672
  if (curr > prev + 1) skips.push({ from: prev, to: curr });
1585
1673
  }
1586
1674
  if (skips.length === 0) {
1587
- return { status: "pass", score: 1, rationale: "No heading-level skips." };
1675
+ return { status: "pass", score: 1, rationale: "No heading-level skips.", rationale_ko: "\uC81C\uBAA9 \uB2E8\uACC4\uAC00 \uC21C\uC11C\uB300\uB85C \uC0AC\uC6A9\uB429\uB2C8\uB2E4." };
1588
1676
  }
1589
1677
  return {
1590
1678
  status: "warn",
1591
1679
  score: Math.max(0.3, 1 - skips.length / levels.length),
1592
1680
  rationale: `${skips.length} heading skip(s) detected (e.g. h${skips[0].from}\u2192h${skips[0].to}).`,
1681
+ rationale_ko: `\uC81C\uBAA9 \uB2E8\uACC4 \uAC74\uB108\uB700\uC774 ${skips.length}\uAC1C \uAC10\uC9C0\uB429\uB2C8\uB2E4 (\uC608: h${skips[0].from}\u2192h${skips[0].to}).`,
1593
1682
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1594
1683
  };
1595
1684
  }
@@ -1606,11 +1695,12 @@ var imageAltRule = defineRule({
1606
1695
  effort: "medium",
1607
1696
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntimage-alt",
1608
1697
  title: "\u226580% of <img> have alt text",
1698
+ title_ko: "<img>\uC758 80% \uC774\uC0C1 alt \uD14D\uC2A4\uD2B8 \uBCF4\uC720 \uC5EC\uBD80",
1609
1699
  description: "Alt text gives AI engines a textual anchor for visual content and improves accessibility.",
1610
1700
  run(ctx) {
1611
1701
  const imgs = ctx.$("img");
1612
1702
  const total = imgs.length;
1613
- if (total === 0) return { status: "skip", score: 0, rationale: "No <img> on the page." };
1703
+ if (total === 0) return { status: "skip", score: 0, rationale: "No <img> on the page.", rationale_ko: "\uD398\uC774\uC9C0\uC5D0 <img>\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
1614
1704
  let withAlt = 0;
1615
1705
  imgs.each((_i, el) => {
1616
1706
  const alt = ctx.$(el).attr("alt");
@@ -1618,12 +1708,13 @@ var imageAltRule = defineRule({
1618
1708
  });
1619
1709
  const ratio = withAlt / total;
1620
1710
  if (ratio >= 0.8) {
1621
- return { status: "pass", score: 1, rationale: `${withAlt}/${total} images have alt (${Math.round(ratio * 100)}%).` };
1711
+ return { status: "pass", score: 1, rationale: `${withAlt}/${total} images have alt (${Math.round(ratio * 100)}%).`, rationale_ko: `\uC774\uBBF8\uC9C0 ${total}\uAC1C \uC911 ${withAlt}\uAC1C\uC5D0 alt\uAC00 \uC788\uC2B5\uB2C8\uB2E4 (${Math.round(ratio * 100)}%).` };
1622
1712
  }
1623
1713
  return {
1624
1714
  status: "warn",
1625
1715
  score: ratio,
1626
1716
  rationale: `Only ${withAlt}/${total} images have alt text (${Math.round(ratio * 100)}%). Aim for \u226580%.`,
1717
+ rationale_ko: `\uC774\uBBF8\uC9C0 ${total}\uAC1C \uC911 ${withAlt}\uAC1C\uB9CC alt \uD14D\uC2A4\uD2B8\uAC00 \uC788\uC2B5\uB2C8\uB2E4 (${Math.round(ratio * 100)}%). 80% \uC774\uC0C1\uC744 \uBAA9\uD45C\uB85C \uD558\uC138\uC694.`,
1627
1718
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1628
1719
  };
1629
1720
  }
@@ -1640,11 +1731,12 @@ var tldrOrFaqRule = defineRule({
1640
1731
  effort: "medium",
1641
1732
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cnttldr-or-faq",
1642
1733
  title: "TL;DR summary or FAQ block",
1734
+ title_ko: "TL;DR \uC694\uC57D \uB610\uB294 FAQ \uBE14\uB85D \uC874\uC7AC \uC5EC\uBD80",
1643
1735
  description: 'AI engines strongly prefer content with a quotable summary or FAQ \u2014 it makes the page "citation-ready".',
1644
1736
  run(ctx) {
1645
1737
  for (const node of flattenJsonLd(ctx.jsonLd)) {
1646
1738
  if (getTypes(node).includes("FAQPage")) {
1647
- return { status: "pass", score: 1, rationale: "FAQPage schema present." };
1739
+ return { status: "pass", score: 1, rationale: "FAQPage schema present.", rationale_ko: "FAQPage \uC2A4\uD0A4\uB9C8\uAC00 \uC788\uC2B5\uB2C8\uB2E4." };
1648
1740
  }
1649
1741
  }
1650
1742
  const sel = [
@@ -1657,12 +1749,13 @@ var tldrOrFaqRule = defineRule({
1657
1749
  "[data-tldr]"
1658
1750
  ].join(", ");
1659
1751
  if (ctx.$(sel).length > 0) {
1660
- return { status: "pass", score: 0.85, rationale: "TL;DR / summary / FAQ region detected by selector." };
1752
+ return { status: "pass", score: 0.85, rationale: "TL;DR / summary / FAQ region detected by selector.", rationale_ko: "TL;DR / \uC694\uC57D / FAQ \uC601\uC5ED\uC774 \uAC10\uC9C0\uB429\uB2C8\uB2E4." };
1661
1753
  }
1662
1754
  return {
1663
1755
  status: "warn",
1664
1756
  score: 0,
1665
1757
  rationale: "No TL;DR / summary / FAQ found; add one to boost AI citation odds.",
1758
+ rationale_ko: "TL;DR / \uC694\uC57D / FAQ\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. AI \uC778\uC6A9 \uAC00\uB2A5\uC131\uC744 \uB192\uC774\uB824\uBA74 \uCD94\uAC00\uD558\uC138\uC694.",
1666
1759
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1667
1760
  };
1668
1761
  }
@@ -1679,6 +1772,7 @@ var wordCountRule = defineRule({
1679
1772
  effort: "high",
1680
1773
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntword-count",
1681
1774
  title: "Page has enough body text",
1775
+ title_ko: "\uCDA9\uBD84\uD55C \uBCF8\uBB38 \uD14D\uC2A4\uD2B8 \uC5EC\uBD80",
1682
1776
  description: "Thin pages (under ~100 words) are rarely cited by AI engines. Aim for \u2265300 words of meaningful body copy.",
1683
1777
  run(ctx) {
1684
1778
  const $ = ctx.$;
@@ -1686,12 +1780,13 @@ var wordCountRule = defineRule({
1686
1780
  clone.find("script, style, noscript, nav, header, footer, aside").remove();
1687
1781
  const text = clone.text().replace(/\s+/g, " ").trim();
1688
1782
  const words = text ? text.split(" ").length : 0;
1689
- if (words >= 300) return { status: "pass", score: 1, rationale: `${words} words of body text.` };
1690
- if (words >= 100) return { status: "warn", score: 0.5, rationale: `Only ${words} words; aim for 300+.` };
1783
+ if (words >= 300) return { status: "pass", score: 1, rationale: `${words} words of body text.`, rationale_ko: `\uBCF8\uBB38 \uD14D\uC2A4\uD2B8\uAC00 ${words}\uB2E8\uC5B4 \uC788\uC2B5\uB2C8\uB2E4.` };
1784
+ if (words >= 100) return { status: "warn", score: 0.5, rationale: `Only ${words} words; aim for 300+.`, rationale_ko: `${words}\uB2E8\uC5B4\uBC16\uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4. 300\uB2E8\uC5B4 \uC774\uC0C1\uC744 \uBAA9\uD45C\uB85C \uD558\uC138\uC694.` };
1691
1785
  return {
1692
1786
  status: "fail",
1693
1787
  score: 0,
1694
1788
  rationale: `Only ${words} words; too thin to be cited.`,
1789
+ rationale_ko: `${words}\uB2E8\uC5B4\uBC16\uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4. AI \uC5D4\uC9C4\uC774 \uC778\uC6A9\uD558\uAE30\uC5D4 \uB108\uBB34 \uC801\uC2B5\uB2C8\uB2E4.`,
1695
1790
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1696
1791
  };
1697
1792
  }
@@ -1742,11 +1837,12 @@ var qaStructureRule = defineRule({
1742
1837
  effort: "medium",
1743
1838
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntqa-structure",
1744
1839
  title: "Content uses Q&A structure for answer extraction",
1840
+ title_ko: "\uB2F5\uBCC0 \uCD94\uCD9C\uC744 \uC704\uD55C Q&A \uAD6C\uC870 \uC0AC\uC6A9 \uC5EC\uBD80",
1745
1841
  description: "Question-style H2/H3 headings (or FAQPage JSON-LD) help AI engines extract direct answers. Pages with \u22652 question headings are far more likely to be cited.",
1746
1842
  run(ctx) {
1747
1843
  for (const node of flattenJsonLd(ctx.jsonLd)) {
1748
1844
  if (getTypes(node).includes("FAQPage")) {
1749
- return { status: "pass", score: 1, rationale: "FAQPage JSON-LD provides explicit Q&A." };
1845
+ return { status: "pass", score: 1, rationale: "FAQPage JSON-LD provides explicit Q&A.", rationale_ko: "FAQPage JSON-LD\uB85C \uBA85\uC2DC\uC801\uC778 Q&A \uAD6C\uC870\uAC00 \uC788\uC2B5\uB2C8\uB2E4." };
1750
1846
  }
1751
1847
  }
1752
1848
  const questionHeadings = [];
@@ -1759,6 +1855,7 @@ var qaStructureRule = defineRule({
1759
1855
  status: "pass",
1760
1856
  score: 1,
1761
1857
  rationale: `${questionHeadings.length} question-style headings detected.`,
1858
+ rationale_ko: `\uC9C8\uBB38\uD615 \uC81C\uBAA9\uC774 ${questionHeadings.length}\uAC1C \uAC10\uC9C0\uB429\uB2C8\uB2E4.`,
1762
1859
  evidence: { headings: questionHeadings.slice(0, 5) }
1763
1860
  };
1764
1861
  }
@@ -1767,6 +1864,7 @@ var qaStructureRule = defineRule({
1767
1864
  status: "warn",
1768
1865
  score: 0.6,
1769
1866
  rationale: "1 question-style heading. Add a second to strengthen answer extraction.",
1867
+ rationale_ko: "\uC9C8\uBB38\uD615 \uC81C\uBAA9\uC774 1\uAC1C\uC785\uB2C8\uB2E4. \uB2F5\uBCC0 \uCD94\uCD9C \uAC15\uD654\uB97C \uC704\uD574 \uD558\uB098 \uB354 \uCD94\uAC00\uD558\uC138\uC694.",
1770
1868
  evidence: { headings: questionHeadings },
1771
1869
  estimatedImpact: 1
1772
1870
  };
@@ -1775,6 +1873,7 @@ var qaStructureRule = defineRule({
1775
1873
  status: "warn",
1776
1874
  score: 0,
1777
1875
  rationale: "No question-style H2/H3 headings or FAQPage JSON-LD found.",
1876
+ rationale_ko: "\uC9C8\uBB38\uD615 H2/H3 \uC81C\uBAA9\uC774\uB098 FAQPage JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.",
1778
1877
  fixHint: 'Reframe at least 2 H2 headings as questions ("How do I\u2026?", "What is\u2026?") or add FAQPage JSON-LD.',
1779
1878
  estimatedImpact: 3
1780
1879
  };
@@ -1792,13 +1891,14 @@ var externalCitationsRule = defineRule({
1792
1891
  effort: "medium",
1793
1892
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntexternal-citations",
1794
1893
  title: "Content cites external sources",
1894
+ title_ko: "\uC678\uBD80 \uCD9C\uCC98 \uC778\uC6A9 \uC5EC\uBD80",
1795
1895
  description: "Outbound links to authoritative external sources are an E-E-A-T trust signal. AI engines treat well-cited pages as more credible.",
1796
1896
  run(ctx) {
1797
1897
  let host;
1798
1898
  try {
1799
1899
  host = new URL(ctx.finalUrl).hostname.toLowerCase();
1800
1900
  } catch {
1801
- return { status: "skip", score: 0, rationale: "Invalid finalUrl." };
1901
+ return { status: "skip", score: 0, rationale: "Invalid finalUrl.", rationale_ko: "\uC720\uD6A8\uD558\uC9C0 \uC54A\uC740 finalUrl\uC785\uB2C8\uB2E4." };
1802
1902
  }
1803
1903
  const seen = /* @__PURE__ */ new Set();
1804
1904
  ctx.$("main a[href], article a[href], body a[href]").each((_i, el) => {
@@ -1823,6 +1923,7 @@ var externalCitationsRule = defineRule({
1823
1923
  status: "pass",
1824
1924
  score: 1,
1825
1925
  rationale: `${count} distinct external host(s) cited (excluding nofollow).`,
1926
+ rationale_ko: `\uC678\uBD80 \uC0AC\uC774\uD2B8 ${count}\uAC1C\uB97C \uC778\uC6A9\uD569\uB2C8\uB2E4 (nofollow \uC81C\uC678).`,
1826
1927
  evidence: { hosts: [...seen].slice(0, 8) }
1827
1928
  };
1828
1929
  }
@@ -1831,6 +1932,7 @@ var externalCitationsRule = defineRule({
1831
1932
  status: "pass",
1832
1933
  score: 0.7,
1833
1934
  rationale: `${count} external host(s) cited. Aim for \u22653 for stronger E-E-A-T.`,
1935
+ rationale_ko: `\uC678\uBD80 \uC0AC\uC774\uD2B8 ${count}\uAC1C\uB97C \uC778\uC6A9\uD569\uB2C8\uB2E4. E-E-A-T \uAC15\uD654\uB97C \uC704\uD574 3\uAC1C \uC774\uC0C1\uC744 \uBAA9\uD45C\uB85C \uD558\uC138\uC694.`,
1834
1936
  evidence: { hosts: [...seen] },
1835
1937
  estimatedImpact: 1
1836
1938
  };
@@ -1839,6 +1941,7 @@ var externalCitationsRule = defineRule({
1839
1941
  status: "warn",
1840
1942
  score: 0,
1841
1943
  rationale: "No external follow citations found in main content.",
1944
+ rationale_ko: "\uBCF8\uBB38\uC5D0 \uC678\uBD80 \uCD9C\uCC98 \uB9C1\uD06C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.",
1842
1945
  fixHint: "Cite at least one authoritative external source (research paper, official docs, news outlet).",
1843
1946
  estimatedImpact: 2
1844
1947
  };
@@ -1856,12 +1959,130 @@ var contentRules = [
1856
1959
  externalCitationsRule
1857
1960
  ];
1858
1961
 
1962
+ // src/rules/aeo/skill-md.ts
1963
+ var aeoSkillMdRule = defineRule({
1964
+ id: "aeo.skill-md",
1965
+ stableId: "aeo.skill-md",
1966
+ category: "aeo",
1967
+ group: "opportunity",
1968
+ weight: 3,
1969
+ impact: "high",
1970
+ effort: "low",
1971
+ docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#aeoskill-md",
1972
+ title: "skill.md is present",
1973
+ title_ko: "skill.md \uD30C\uC77C \uC874\uC7AC \uC5EC\uBD80",
1974
+ description: "A /skill.md file describes site capabilities so AI agents know what this site can do for them.",
1975
+ run(ctx) {
1976
+ if (ctx.skillMd !== null) {
1977
+ return {
1978
+ status: "pass",
1979
+ score: 1,
1980
+ rationale: "skill.md found at site root.",
1981
+ rationale_ko: "skill.md\uAC00 \uC0AC\uC774\uD2B8 \uB8E8\uD2B8\uC5D0 \uC874\uC7AC\uD569\uB2C8\uB2E4."
1982
+ };
1983
+ }
1984
+ return {
1985
+ status: "warn",
1986
+ score: 0,
1987
+ rationale: "No /skill.md found. Add one to describe your site capabilities to AI agents.",
1988
+ rationale_ko: "/skill.md\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. AI \uC5D0\uC774\uC804\uD2B8\uAC00 \uC0AC\uC774\uD2B8 \uAE30\uB2A5\uC744 \uD30C\uC545\uD560 \uC218 \uC788\uB3C4\uB85D \uCD94\uAC00\uD558\uC138\uC694.",
1989
+ fixHint: "Create /skill.md listing what services, products, and capabilities this site offers."
1990
+ };
1991
+ }
1992
+ });
1993
+
1994
+ // src/rules/aeo/agent-permissions.ts
1995
+ var aeoAgentPermissionsRule = defineRule({
1996
+ id: "aeo.agent-permissions",
1997
+ stableId: "aeo.agent-permissions",
1998
+ category: "aeo",
1999
+ group: "opportunity",
2000
+ weight: 3,
2001
+ impact: "medium",
2002
+ effort: "low",
2003
+ docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#aeoagent-permissions",
2004
+ title: "agent-permissions.json is present",
2005
+ title_ko: "agent-permissions.json \uD30C\uC77C \uC874\uC7AC \uC5EC\uBD80",
2006
+ description: "Declares explicit read/summarize/cite/train permissions for AI agents.",
2007
+ run(ctx) {
2008
+ if (ctx.agentPermissions !== null) {
2009
+ return {
2010
+ status: "pass",
2011
+ score: 1,
2012
+ rationale: "agent-permissions.json found at site root.",
2013
+ rationale_ko: "agent-permissions.json\uC774 \uC0AC\uC774\uD2B8 \uB8E8\uD2B8\uC5D0 \uC874\uC7AC\uD569\uB2C8\uB2E4.",
2014
+ evidence: ctx.agentPermissions
2015
+ };
2016
+ }
2017
+ return {
2018
+ status: "warn",
2019
+ score: 0,
2020
+ rationale: "No /agent-permissions.json found. Add one to declare AI agent access policy.",
2021
+ rationale_ko: "/agent-permissions.json\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. AI \uC5D0\uC774\uC804\uD2B8 \uC811\uADFC \uC815\uCC45\uC744 \uBA85\uC2DC\uD558\uB824\uBA74 \uCD94\uAC00\uD558\uC138\uC694.",
2022
+ fixHint: "Create /agent-permissions.json with read, summarize, cite, and train permission flags."
2023
+ };
2024
+ }
2025
+ });
2026
+
2027
+ // src/rules/aeo/token-length.ts
2028
+ var THRESHOLD_OPTIMAL = 15e3;
2029
+ var THRESHOLD_MAX = 25e3;
2030
+ var aeoTokenLengthRule = defineRule({
2031
+ id: "aeo.token-length",
2032
+ stableId: "aeo.token-length",
2033
+ category: "aeo",
2034
+ group: "diagnostic",
2035
+ weight: 4,
2036
+ impact: "medium",
2037
+ effort: "medium",
2038
+ docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#aeotoken-length",
2039
+ title: "Content token length within AI agent limits",
2040
+ title_ko: "\uCF58\uD150\uCE20 \uD1A0\uD070 \uC218 AI \uC5D0\uC774\uC804\uD2B8 \uAD8C\uC7A5 \uBC94\uC704",
2041
+ description: "Pages under 15K tokens are optimal for AI agents (per Addy Osmani's AEO guidance).",
2042
+ run(ctx) {
2043
+ const text = ctx.$("body").text();
2044
+ const tokenEstimate = Math.round(text.length / 3);
2045
+ const evidence = { tokenEstimate, thresholds: { optimal: THRESHOLD_OPTIMAL, max: THRESHOLD_MAX } };
2046
+ if (tokenEstimate <= THRESHOLD_OPTIMAL) {
2047
+ return {
2048
+ status: "pass",
2049
+ score: 1,
2050
+ rationale: `Estimated ~${tokenEstimate.toLocaleString()} tokens \u2014 within optimal range.`,
2051
+ rationale_ko: `\uC608\uC0C1 \uD1A0\uD070 \uC218 ~${tokenEstimate.toLocaleString()} \u2014 \uAD8C\uC7A5 \uBC94\uC704(15K) \uC774\uB0B4\uC785\uB2C8\uB2E4.`,
2052
+ evidence
2053
+ };
2054
+ }
2055
+ if (tokenEstimate <= THRESHOLD_MAX) {
2056
+ return {
2057
+ status: "warn",
2058
+ score: 0.5,
2059
+ rationale: `Estimated ~${tokenEstimate.toLocaleString()} tokens \u2014 exceeds 15K recommendation.`,
2060
+ rationale_ko: `\uC608\uC0C1 \uD1A0\uD070 \uC218 ~${tokenEstimate.toLocaleString()} \u2014 15K \uAD8C\uC7A5\uCE58\uB97C \uCD08\uACFC\uD569\uB2C8\uB2E4.`,
2061
+ fixHint: "Consider splitting content into shorter, focused pages.",
2062
+ evidence
2063
+ };
2064
+ }
2065
+ return {
2066
+ status: "fail",
2067
+ score: 0,
2068
+ rationale: `Estimated ~${tokenEstimate.toLocaleString()} tokens \u2014 exceeds 25K agent processing limit.`,
2069
+ rationale_ko: `\uC608\uC0C1 \uD1A0\uD070 \uC218 ~${tokenEstimate.toLocaleString()} \u2014 AI \uC5D0\uC774\uC804\uD2B8 \uCC98\uB9AC \uD55C\uACC4(25K)\uB97C \uCD08\uACFC\uD569\uB2C8\uB2E4.`,
2070
+ fixHint: "Split this page into multiple focused pages under 15K tokens.",
2071
+ evidence
2072
+ };
2073
+ }
2074
+ });
2075
+
2076
+ // src/rules/aeo/index.ts
2077
+ var aeoRules = [aeoSkillMdRule, aeoAgentPermissionsRule, aeoTokenLengthRule];
2078
+
1859
2079
  // src/rules/index.ts
1860
2080
  var defaultRules = [
1861
2081
  ...crawlerRules,
1862
2082
  ...structuredDataRules,
1863
2083
  ...citationRules,
1864
- ...contentRules
2084
+ ...contentRules,
2085
+ ...aeoRules
1865
2086
  ];
1866
2087
 
1867
2088
  // src/config.ts
@@ -2057,7 +2278,8 @@ var CATEGORY_LABELS = {
2057
2278
  crawler: "AI Crawler Access",
2058
2279
  "structured-data": "Structured Data",
2059
2280
  citation: "Citation Signals",
2060
- content: "Content Structure"
2281
+ content: "Content Structure",
2282
+ aeo: "AEO Stack"
2061
2283
  };
2062
2284
  function scoreBadge(score) {
2063
2285
  const color = score >= 85 ? "brightgreen" : score >= 60 ? "yellow" : "red";