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/cli.cjs CHANGED
@@ -286,10 +286,12 @@ async function buildContext(url, opts = {}) {
286
286
  "Site appears to be JS-rendered (sparse body + SPA root element). Re-run with --render for accurate results."
287
287
  );
288
288
  }
289
- const [robotsRaw, llmsRaw, llmsFullRaw] = await Promise.all([
289
+ const [robotsRaw, llmsRaw, llmsFullRaw, skillMdRaw, agentPermissionsRaw] = await Promise.all([
290
290
  fetchText(`${origin}/robots.txt`, opts),
291
291
  fetchText(`${origin}/llms.txt`, opts),
292
- fetchText(`${origin}/llms-full.txt`, opts)
292
+ fetchText(`${origin}/llms-full.txt`, opts),
293
+ fetchText(`${origin}/skill.md`, opts),
294
+ fetchText(`${origin}/agent-permissions.json`, opts)
293
295
  ]);
294
296
  let sitemapUrl = null;
295
297
  const robots = robotsRaw ? parseRobots(robotsRaw) : null;
@@ -297,6 +299,13 @@ async function buildContext(url, opts = {}) {
297
299
  if (!sitemapUrl) sitemapUrl = `${origin}/sitemap.xml`;
298
300
  const sitemapRaw = await fetchText(sitemapUrl, opts);
299
301
  const sitemap = sitemapRaw ? parseSitemap(sitemapRaw) : null;
302
+ let agentPermissions = null;
303
+ if (agentPermissionsRaw && agentPermissionsRaw.trim().length > 0) {
304
+ try {
305
+ agentPermissions = JSON.parse(agentPermissionsRaw);
306
+ } catch {
307
+ }
308
+ }
300
309
  return {
301
310
  url,
302
311
  finalUrl,
@@ -311,7 +320,9 @@ async function buildContext(url, opts = {}) {
311
320
  jsonLd: extractJsonLd($),
312
321
  renderMode,
313
322
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
314
- warnings
323
+ warnings,
324
+ skillMd: skillMdRaw && skillMdRaw.trim().length > 0 ? skillMdRaw : null,
325
+ agentPermissions
315
326
  };
316
327
  }
317
328
 
@@ -320,10 +331,11 @@ function defineRule(rule) {
320
331
  return rule;
321
332
  }
322
333
  var CATEGORY_WEIGHTS = {
323
- crawler: 25,
324
- "structured-data": 30,
325
- citation: 25,
326
- content: 20
334
+ crawler: 20,
335
+ "structured-data": 25,
336
+ citation: 20,
337
+ content: 15,
338
+ aeo: 20
327
339
  };
328
340
 
329
341
  // src/engine.ts
@@ -337,7 +349,8 @@ async function runRules(ctx, rules, opts = {}) {
337
349
  crawler: { score: 0, weight: weights.crawler, results: [] },
338
350
  "structured-data": { score: 0, weight: weights["structured-data"], results: [] },
339
351
  citation: { score: 0, weight: weights.citation, results: [] },
340
- content: { score: 0, weight: weights.content, results: [] }
352
+ content: { score: 0, weight: weights.content, results: [] },
353
+ aeo: { score: 0, weight: weights.aeo, results: [] }
341
354
  };
342
355
  for (const rule of rules) {
343
356
  if (onlySet && !onlySet.has(rule.id) && (!rule.stableId || !onlySet.has(rule.stableId))) continue;
@@ -362,6 +375,7 @@ async function runRules(ctx, rules, opts = {}) {
362
375
  durationMs
363
376
  };
364
377
  if (rule.stableId !== void 0) entry.stableId = rule.stableId;
378
+ if (rule.title_ko !== void 0) entry.title_ko = rule.title_ko;
365
379
  if (rule.group !== void 0) entry.group = rule.group;
366
380
  if (rule.impact !== void 0) entry.impact = rule.impact;
367
381
  if (rule.effort !== void 0) entry.effort = rule.effort;
@@ -424,13 +438,15 @@ var httpsRule = defineRule({
424
438
  effort: "medium",
425
439
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerhttps",
426
440
  title: "Site is served over HTTPS",
441
+ title_ko: "\uC0AC\uC774\uD2B8\uAC00 HTTPS\uB85C \uC81C\uACF5\uB428",
427
442
  description: "AI crawlers treat HTTPS pages as more trustworthy and some skip plain HTTP entirely.",
428
443
  run(ctx) {
429
444
  const isHttps = ctx.finalUrl.startsWith("https://");
430
- return isHttps ? { status: "pass", score: 1, rationale: "Final URL uses HTTPS." } : {
445
+ return isHttps ? { status: "pass", score: 1, rationale: "Final URL uses HTTPS.", rationale_ko: "\uCD5C\uC885 URL\uC774 HTTPS\uB97C \uC0AC\uC6A9\uD569\uB2C8\uB2E4." } : {
431
446
  status: "fail",
432
447
  score: 0,
433
448
  rationale: "Final URL does not use HTTPS. Redirect HTTP \u2192 HTTPS site-wide.",
449
+ 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.",
434
450
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
435
451
  };
436
452
  }
@@ -447,15 +463,17 @@ var robotsReachableRule = defineRule({
447
463
  effort: "low",
448
464
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerrobots-reachable",
449
465
  title: "robots.txt is reachable",
466
+ title_ko: "robots.txt \uC811\uADFC \uAC00\uB2A5 \uC5EC\uBD80",
450
467
  description: "A reachable robots.txt lets crawlers confirm their permissions; missing file is treated as allow-all but blocks explicit signalling.",
451
468
  run(ctx) {
452
469
  if (ctx.robots) {
453
- return { status: "pass", score: 1, rationale: "robots.txt returned successfully." };
470
+ 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." };
454
471
  }
455
472
  return {
456
473
  status: "warn",
457
474
  score: 0.3,
458
475
  rationale: "robots.txt is missing. Add one even if empty to explicitly signal crawl policy.",
476
+ 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.",
459
477
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
460
478
  };
461
479
  }
@@ -503,13 +521,15 @@ var robotsAiAllowRule = defineRule({
503
521
  effort: "low",
504
522
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerrobots-ai-allow",
505
523
  title: "AI crawlers are allowed",
524
+ title_ko: "AI \uD06C\uB864\uB7EC \uC811\uADFC \uD5C8\uC6A9 \uC5EC\uBD80",
506
525
  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.",
507
526
  run(ctx) {
508
527
  if (!ctx.robots) {
509
528
  return {
510
529
  status: "warn",
511
530
  score: 0.5,
512
- rationale: "robots.txt missing; AI crawlers default to allow, but explicit allow is recommended."
531
+ rationale: "robots.txt missing; AI crawlers default to allow, but explicit allow is recommended.",
532
+ 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."
513
533
  };
514
534
  }
515
535
  const path = new URL(ctx.finalUrl).pathname || "/";
@@ -527,6 +547,7 @@ var robotsAiAllowRule = defineRule({
527
547
  status: "fail",
528
548
  score: Math.max(0, 1 - blocked.length / AI_BOTS.length),
529
549
  rationale: `Blocked: ${blocked.join(", ")}. Remove the Disallow or add an explicit Allow for these user-agents.`,
550
+ 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.`,
530
551
  evidence: { blocked, mentioned, totalBots: AI_BOTS.length },
531
552
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
532
553
  };
@@ -535,13 +556,15 @@ var robotsAiAllowRule = defineRule({
535
556
  return {
536
557
  status: "warn",
537
558
  score: 0.6,
538
- rationale: `All ${AI_BOTS.length} AI crawlers reach the page via default rules, but none are explicitly listed. Consider explicit Allow entries.`
559
+ rationale: `All ${AI_BOTS.length} AI crawlers reach the page via default rules, but none are explicitly listed. Consider explicit Allow entries.`,
560
+ 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.`
539
561
  };
540
562
  }
541
563
  return {
542
564
  status: "pass",
543
565
  score: 1,
544
566
  rationale: `All ${AI_BOTS.length} AI crawlers can reach the page; ${mentioned.length} explicitly listed.`,
567
+ 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.`,
545
568
  evidence: { mentioned, totalBots: AI_BOTS.length }
546
569
  };
547
570
  }
@@ -558,15 +581,17 @@ var llmsTxtPresentRule = defineRule({
558
581
  effort: "medium",
559
582
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerllms-txt-present",
560
583
  title: "llms.txt is present",
584
+ title_ko: "llms.txt \uD30C\uC77C \uC874\uC7AC \uC5EC\uBD80",
561
585
  description: "An /llms.txt file at the site root gives AI assistants a curated map of the most citation-worthy pages.",
562
586
  run(ctx) {
563
587
  if (ctx.llmsTxt) {
564
- return { status: "pass", score: 1, rationale: "llms.txt found at site root." };
588
+ 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." };
565
589
  }
566
590
  return {
567
591
  status: "warn",
568
592
  score: 0,
569
593
  rationale: "No /llms.txt found. Add one to curate the pages AI assistants should read.",
594
+ 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.",
570
595
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
571
596
  };
572
597
  }
@@ -583,10 +608,11 @@ var llmsTxtWellformedRule = defineRule({
583
608
  effort: "low",
584
609
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerllms-txt-wellformed",
585
610
  title: "llms.txt follows the spec",
611
+ title_ko: "llms.txt \uC2A4\uD399 \uC900\uC218 \uC5EC\uBD80",
586
612
  description: "Must start with an H1 project title, then a brief summary, then at least one H2 section containing link items.",
587
613
  run(ctx) {
588
614
  if (!ctx.llmsTxt) {
589
- return { status: "skip", score: 0, rationale: "No llms.txt to validate." };
615
+ return { status: "skip", score: 0, rationale: "No llms.txt to validate.", rationale_ko: "\uAC80\uC99D\uD560 llms.txt\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
590
616
  }
591
617
  const check = isLlmsTxtWellFormed(ctx.llmsTxt);
592
618
  if (check.ok) {
@@ -594,13 +620,15 @@ var llmsTxtWellformedRule = defineRule({
594
620
  return {
595
621
  status: "pass",
596
622
  score: 1,
597
- rationale: `Well-formed with ${ctx.llmsTxt.sections.length} section(s) and ${totalLinks} link(s).`
623
+ rationale: `Well-formed with ${ctx.llmsTxt.sections.length} section(s) and ${totalLinks} link(s).`,
624
+ rationale_ko: `\uC2A4\uD399\uC5D0 \uB9DE\uAC8C \uC791\uC131\uB428 (\uC139\uC158 ${ctx.llmsTxt.sections.length}\uAC1C, \uB9C1\uD06C ${totalLinks}\uAC1C).`
598
625
  };
599
626
  }
600
627
  return {
601
628
  status: "warn",
602
629
  score: 0.3,
603
630
  rationale: `llms.txt does not fully match the spec: ${check.reason}.`,
631
+ rationale_ko: `llms.txt\uAC00 \uC2A4\uD399\uC744 \uC644\uC804\uD788 \uB530\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4: ${check.reason}.`,
604
632
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
605
633
  };
606
634
  }
@@ -617,13 +645,15 @@ var llmsFullTxtRule = defineRule({
617
645
  effort: "medium",
618
646
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerllms-full-txt",
619
647
  title: "llms-full.txt provides full-content mirror",
648
+ title_ko: "llms-full.txt \uC804\uCCB4 \uCF58\uD150\uCE20 \uBBF8\uB7EC \uC81C\uACF5 \uC5EC\uBD80",
620
649
  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.",
621
650
  run(ctx) {
622
651
  if (ctx.llmsFullTxt && ctx.llmsFullTxt.length > 200) {
623
652
  return {
624
653
  status: "pass",
625
654
  score: 1,
626
- rationale: `/llms-full.txt found (${ctx.llmsFullTxt.length.toLocaleString()} chars).`
655
+ rationale: `/llms-full.txt found (${ctx.llmsFullTxt.length.toLocaleString()} chars).`,
656
+ rationale_ko: `/llms-full.txt\uAC00 \uC874\uC7AC\uD569\uB2C8\uB2E4 (${ctx.llmsFullTxt.length.toLocaleString()}\uC790).`
627
657
  };
628
658
  }
629
659
  if (ctx.llmsFullTxt) {
@@ -631,6 +661,7 @@ var llmsFullTxtRule = defineRule({
631
661
  status: "warn",
632
662
  score: 0.5,
633
663
  rationale: `/llms-full.txt found but very short (${ctx.llmsFullTxt.length} chars). Consider expanding with page bodies.`,
664
+ 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.`,
634
665
  fixHint: "Mirror full article bodies into /llms-full.txt so AI assistants can quote without re-crawling."
635
666
  };
636
667
  }
@@ -638,6 +669,7 @@ var llmsFullTxtRule = defineRule({
638
669
  status: "warn",
639
670
  score: 0,
640
671
  rationale: "No /llms-full.txt found. Adding one lets AI assistants ingest the full corpus in a single request.",
672
+ 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.",
641
673
  fixHint: "Publish /llms-full.txt alongside /llms.txt with the full body text of your top pages.",
642
674
  estimatedImpact: 1
643
675
  };
@@ -655,19 +687,22 @@ var sitemapPresentRule = defineRule({
655
687
  effort: "low",
656
688
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlersitemap-present",
657
689
  title: "sitemap.xml is present",
690
+ title_ko: "sitemap.xml \uC874\uC7AC \uC5EC\uBD80",
658
691
  description: "A sitemap helps AI crawlers discover and prioritise pages; many crawlers short-circuit discovery without one.",
659
692
  run(ctx) {
660
693
  if (ctx.sitemap && ctx.sitemap.urls.length > 0) {
661
694
  return {
662
695
  status: "pass",
663
696
  score: 1,
664
- rationale: `Sitemap found with ${ctx.sitemap.urls.length} URL(s).`
697
+ rationale: `Sitemap found with ${ctx.sitemap.urls.length} URL(s).`,
698
+ rationale_ko: `\uC0AC\uC774\uD2B8\uB9F5\uC5D0 URL\uC774 ${ctx.sitemap.urls.length}\uAC1C \uC788\uC2B5\uB2C8\uB2E4.`
665
699
  };
666
700
  }
667
701
  return {
668
702
  status: "warn",
669
703
  score: 0.2,
670
704
  rationale: "No sitemap.xml found (checked /sitemap.xml and Sitemap: directive in robots.txt).",
705
+ 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).",
671
706
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
672
707
  };
673
708
  }
@@ -695,15 +730,17 @@ var jsonLdPresentRule = defineRule({
695
730
  effort: "medium",
696
731
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdjsonld-present",
697
732
  title: "JSON-LD structured data is present",
733
+ title_ko: "JSON-LD \uAD6C\uC870\uD654 \uB370\uC774\uD130 \uC874\uC7AC \uC5EC\uBD80",
698
734
  description: 'At least one <script type="application/ld+json"> block is the primary way AI engines map your page to an entity.',
699
735
  run(ctx) {
700
736
  if (ctx.jsonLd.length > 0) {
701
- return { status: "pass", score: 1, rationale: `Found ${ctx.jsonLd.length} JSON-LD block(s).` };
737
+ 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.` };
702
738
  }
703
739
  return {
704
740
  status: "fail",
705
741
  score: 0,
706
742
  rationale: "No JSON-LD blocks found. Add schema.org structured data.",
743
+ 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.",
707
744
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
708
745
  };
709
746
  }
@@ -779,20 +816,22 @@ var jsonLdValidJsonRule = defineRule({
779
816
  effort: "low",
780
817
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdjsonld-valid-json",
781
818
  title: "JSON-LD blocks parse as valid JSON",
819
+ title_ko: "JSON-LD \uBE14\uB85D\uC758 JSON \uC720\uD6A8\uC131",
782
820
  description: "Malformed JSON in an ld+json block is silently ignored by most consumers \u2014 a costly silent failure.",
783
821
  run(ctx) {
784
822
  if (ctx.jsonLd.length === 0) {
785
- return { status: "skip", score: 0, rationale: "No JSON-LD to validate." };
823
+ return { status: "skip", score: 0, rationale: "No JSON-LD to validate.", rationale_ko: "\uAC80\uC99D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
786
824
  }
787
825
  if (hasParseError(ctx.jsonLd)) {
788
826
  return {
789
827
  status: "fail",
790
828
  score: 0,
791
829
  rationale: "One or more JSON-LD blocks failed to parse.",
830
+ rationale_ko: "JSON-LD \uBE14\uB85D \uD558\uB098 \uC774\uC0C1\uC744 \uD30C\uC2F1\uD558\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.",
792
831
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
793
832
  };
794
833
  }
795
- return { status: "pass", score: 1, rationale: "All JSON-LD blocks parse cleanly." };
834
+ 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." };
796
835
  }
797
836
  });
798
837
 
@@ -807,10 +846,11 @@ var schemaTypeRecognizedRule = defineRule({
807
846
  effort: "low",
808
847
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdschema-type-recognized",
809
848
  title: "Schema.org @type is a recognised kind",
849
+ title_ko: "Schema.org @type \uC778\uC2DD \uAC00\uB2A5 \uC5EC\uBD80",
810
850
  description: "AI engines match pages against well-known types (Article, Product, FAQPage...). Obscure types weaken the signal.",
811
851
  run(ctx) {
812
852
  if (ctx.jsonLd.length === 0) {
813
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
853
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
814
854
  }
815
855
  const nodes = flattenJsonLd(ctx.jsonLd);
816
856
  const recognized = /* @__PURE__ */ new Set();
@@ -826,6 +866,7 @@ var schemaTypeRecognizedRule = defineRule({
826
866
  status: "pass",
827
867
  score: 1,
828
868
  rationale: `Recognised: ${[...recognized].join(", ")}.`,
869
+ rationale_ko: `\uC778\uC2DD\uB41C \uD0C0\uC785: ${[...recognized].join(", ")}.`,
829
870
  evidence: { recognized: [...recognized], all: [...seenTypes] }
830
871
  };
831
872
  }
@@ -833,6 +874,7 @@ var schemaTypeRecognizedRule = defineRule({
833
874
  status: "warn",
834
875
  score: 0.3,
835
876
  rationale: `No recognised schema.org types. Saw: ${[...seenTypes].join(", ") || "(none)"}.`,
877
+ 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)"}.`,
836
878
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
837
879
  };
838
880
  }
@@ -849,10 +891,11 @@ var requiredFieldsRule = defineRule({
849
891
  effort: "medium",
850
892
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdrequired-fields",
851
893
  title: "Required fields for recognised types are set",
894
+ title_ko: "\uC778\uC2DD\uB41C \uD0C0\uC785\uC758 \uD544\uC218 \uD544\uB4DC \uCDA9\uC871 \uC5EC\uBD80",
852
895
  description: "Article needs headline/author/datePublished, FAQPage needs mainEntity, Product needs offers, etc.",
853
896
  run(ctx) {
854
897
  if (ctx.jsonLd.length === 0) {
855
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
898
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
856
899
  }
857
900
  const nodes = flattenJsonLd(ctx.jsonLd);
858
901
  const missing = [];
@@ -871,14 +914,16 @@ var requiredFieldsRule = defineRule({
871
914
  return {
872
915
  status: "skip",
873
916
  score: 0,
874
- rationale: "No types with known required fields were found."
917
+ rationale: "No types with known required fields were found.",
918
+ rationale_ko: "\uD544\uC218 \uD544\uB4DC\uAC00 \uC815\uC758\uB41C \uD0C0\uC785\uC774 \uC5C6\uC2B5\uB2C8\uB2E4."
875
919
  };
876
920
  }
877
921
  if (missing.length === 0) {
878
922
  return {
879
923
  status: "pass",
880
924
  score: 1,
881
- rationale: `Required fields set on ${checked.length} node(s).`
925
+ rationale: `Required fields set on ${checked.length} node(s).`,
926
+ rationale_ko: `${checked.length}\uAC1C \uB178\uB4DC\uC758 \uD544\uC218 \uD544\uB4DC\uAC00 \uBAA8\uB450 \uCDA9\uC871\uB429\uB2C8\uB2E4.`
882
927
  };
883
928
  }
884
929
  const msg = missing.map((m) => `${m.type}.${m.field}`).join(", ");
@@ -886,6 +931,7 @@ var requiredFieldsRule = defineRule({
886
931
  status: "fail",
887
932
  score: Math.max(0, 1 - missing.length / (checked.length * 2)),
888
933
  rationale: `Missing required fields: ${msg}.`,
934
+ rationale_ko: `\uB204\uB77D\uB41C \uD544\uC218 \uD544\uB4DC: ${msg}.`,
889
935
  evidence: missing,
890
936
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
891
937
  };
@@ -903,10 +949,11 @@ var microdataFallbackRule = defineRule({
903
949
  effort: "medium",
904
950
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdmicrodata-fallback",
905
951
  title: "Microdata or RDFa fallback when JSON-LD is missing",
952
+ title_ko: "JSON-LD \uC5C6\uC744 \uB54C Microdata/RDFa \uB300\uCCB4 \uC5EC\uBD80",
906
953
  description: "If JSON-LD is absent, inline microdata (itemscope/itemtype) or RDFa still gives some structured signal.",
907
954
  run(ctx) {
908
955
  if (ctx.jsonLd.length > 0) {
909
- return { status: "skip", score: 0, rationale: "JSON-LD is present; fallback not needed." };
956
+ 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." };
910
957
  }
911
958
  const microdata = ctx.$("[itemscope][itemtype]").length;
912
959
  const rdfa = ctx.$("[typeof][vocab], [typeof][property]").length;
@@ -914,13 +961,15 @@ var microdataFallbackRule = defineRule({
914
961
  return {
915
962
  status: "pass",
916
963
  score: 1,
917
- rationale: `Found ${microdata} microdata and ${rdfa} RDFa nodes.`
964
+ rationale: `Found ${microdata} microdata and ${rdfa} RDFa nodes.`,
965
+ rationale_ko: `Microdata ${microdata}\uAC1C, RDFa ${rdfa}\uAC1C \uBC1C\uACAC\uB429\uB2C8\uB2E4.`
918
966
  };
919
967
  }
920
968
  return {
921
969
  status: "fail",
922
970
  score: 0,
923
971
  rationale: "No structured data at all (no JSON-LD, no microdata, no RDFa).",
972
+ rationale_ko: "\uAD6C\uC870\uD654 \uB370\uC774\uD130\uAC00 \uC804\uD600 \uC5C6\uC2B5\uB2C8\uB2E4 (JSON-LD, Microdata, RDFa \uBAA8\uB450 \uC5C6\uC74C).",
924
973
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
925
974
  };
926
975
  }
@@ -938,10 +987,11 @@ var noDuplicateTypesRule = defineRule({
938
987
  effort: "low",
939
988
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdno-duplicate-types",
940
989
  title: "No conflicting duplicate @types",
990
+ title_ko: "@type \uC911\uBCF5 \uCDA9\uB3CC \uC5C6\uC74C",
941
991
  description: "Multiple competing entities of the same primary type (e.g. two Articles) confuse the engine about which one represents the page.",
942
992
  run(ctx) {
943
993
  if (ctx.jsonLd.length === 0) {
944
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
994
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
945
995
  }
946
996
  const counts = /* @__PURE__ */ new Map();
947
997
  for (const node of flattenJsonLd(ctx.jsonLd)) {
@@ -951,12 +1001,13 @@ var noDuplicateTypesRule = defineRule({
951
1001
  }
952
1002
  const dupes = [...counts.entries()].filter(([, n]) => n > 1);
953
1003
  if (dupes.length === 0) {
954
- return { status: "pass", score: 1, rationale: "No duplicate primary types." };
1004
+ return { status: "pass", score: 1, rationale: "No duplicate primary types.", rationale_ko: "\uC911\uBCF5\uB41C \uAE30\uBCF8 \uD0C0\uC785\uC774 \uC5C6\uC2B5\uB2C8\uB2E4." };
955
1005
  }
956
1006
  return {
957
1007
  status: "warn",
958
1008
  score: 0.4,
959
1009
  rationale: `Duplicate primary types: ${dupes.map(([t, n]) => `${t}\xD7${n}`).join(", ")}.`,
1010
+ rationale_ko: `\uC911\uBCF5\uB41C \uAE30\uBCF8 \uD0C0\uC785: ${dupes.map(([t, n]) => `${t}\xD7${n}`).join(", ")}.`,
960
1011
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
961
1012
  };
962
1013
  }
@@ -1004,10 +1055,11 @@ var sameAsEntityRule = defineRule({
1004
1055
  effort: "medium",
1005
1056
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdsameas-entity",
1006
1057
  title: "Entity nodes link the knowledge graph via sameAs",
1058
+ title_ko: "sameAs\uB85C \uC9C0\uC2DD \uADF8\uB798\uD504 \uC5F0\uACB0 \uC5EC\uBD80",
1007
1059
  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).",
1008
1060
  run(ctx) {
1009
1061
  if (ctx.jsonLd.length === 0) {
1010
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
1062
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
1011
1063
  }
1012
1064
  const nodes = flattenJsonLd(ctx.jsonLd);
1013
1065
  const entities = nodes.filter((n) => getTypes(n).some((t) => ENTITY_TYPES.includes(t)));
@@ -1015,7 +1067,8 @@ var sameAsEntityRule = defineRule({
1015
1067
  return {
1016
1068
  status: "skip",
1017
1069
  score: 0,
1018
- rationale: "No Organization/Person/LocalBusiness/Brand entity to link."
1070
+ rationale: "No Organization/Person/LocalBusiness/Brand entity to link.",
1071
+ rationale_ko: "\uC5F0\uACB0\uD560 Organization/Person/LocalBusiness/Brand \uC5D4\uD2F0\uD2F0\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4."
1019
1072
  };
1020
1073
  }
1021
1074
  let bestScore = 0;
@@ -1039,6 +1092,7 @@ var sameAsEntityRule = defineRule({
1039
1092
  status: "pass",
1040
1093
  score: 1,
1041
1094
  rationale: `Entity links ${bestEvidence.trusted} trusted knowledge-graph hosts via sameAs.`,
1095
+ 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.`,
1042
1096
  evidence: bestEvidence
1043
1097
  };
1044
1098
  }
@@ -1047,6 +1101,7 @@ var sameAsEntityRule = defineRule({
1047
1101
  status: "pass",
1048
1102
  score: bestScore,
1049
1103
  rationale: `Entity has 1 trusted sameAs link. Add Wikipedia/Wikidata for stronger E-E-A-T.`,
1104
+ 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.",
1050
1105
  evidence: bestEvidence,
1051
1106
  estimatedImpact: 1
1052
1107
  };
@@ -1056,6 +1111,7 @@ var sameAsEntityRule = defineRule({
1056
1111
  status: "warn",
1057
1112
  score: bestScore,
1058
1113
  rationale: "Entity declares sameAs but no trusted knowledge-graph hosts (Wikipedia/Wikidata/LinkedIn).",
1114
+ 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.",
1059
1115
  evidence: bestEvidence,
1060
1116
  fixHint: "Add Wikipedia/Wikidata/LinkedIn URLs to your Organization sameAs[].",
1061
1117
  estimatedImpact: 2
@@ -1065,6 +1121,7 @@ var sameAsEntityRule = defineRule({
1065
1121
  status: "warn",
1066
1122
  score: 0,
1067
1123
  rationale: `${entities.length} entity node(s) found but none declare sameAs links.`,
1124
+ rationale_ko: `\uC5D4\uD2F0\uD2F0 \uB178\uB4DC\uAC00 ${entities.length}\uAC1C \uC788\uC9C0\uB9CC sameAs \uB9C1\uD06C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.`,
1068
1125
  fixHint: 'Add sameAs:["https://en.wikipedia.org/wiki/...", "https://www.linkedin.com/company/..."] to your Organization JSON-LD.',
1069
1126
  estimatedImpact: 3
1070
1127
  };
@@ -1096,15 +1153,16 @@ var breadcrumbValidRule = defineRule({
1096
1153
  effort: "medium",
1097
1154
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdbreadcrumb-valid",
1098
1155
  title: "BreadcrumbList items declare position, name, and item",
1156
+ title_ko: "BreadcrumbList \uD56D\uBAA9\uC758 \uD544\uC218 \uD544\uB4DC \uCDA9\uC871 \uC5EC\uBD80",
1099
1157
  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.",
1100
1158
  run(ctx) {
1101
1159
  if (ctx.jsonLd.length === 0) {
1102
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
1160
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
1103
1161
  }
1104
1162
  const nodes = flattenJsonLd(ctx.jsonLd);
1105
1163
  const breadcrumbs = nodes.filter((n) => getTypes(n).includes("BreadcrumbList"));
1106
1164
  if (breadcrumbs.length === 0) {
1107
- return { status: "skip", score: 0, rationale: "No BreadcrumbList present." };
1165
+ return { status: "skip", score: 0, rationale: "No BreadcrumbList present.", rationale_ko: "BreadcrumbList\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
1108
1166
  }
1109
1167
  const allIssues = [];
1110
1168
  let totalItems = 0;
@@ -1117,7 +1175,8 @@ var breadcrumbValidRule = defineRule({
1117
1175
  return {
1118
1176
  status: "pass",
1119
1177
  score: 1,
1120
- rationale: `BreadcrumbList(s) valid (${totalItems} items).`
1178
+ rationale: `BreadcrumbList(s) valid (${totalItems} items).`,
1179
+ rationale_ko: `BreadcrumbList\uAC00 \uC720\uD6A8\uD569\uB2C8\uB2E4 (\uD56D\uBAA9 ${totalItems}\uAC1C).`
1121
1180
  };
1122
1181
  }
1123
1182
  const fatalCount = allIssues.length;
@@ -1127,6 +1186,7 @@ var breadcrumbValidRule = defineRule({
1127
1186
  status: score < 0.5 ? "fail" : "warn",
1128
1187
  score,
1129
1188
  rationale: `${fatalCount} breadcrumb item(s) missing required fields.`,
1189
+ rationale_ko: `breadcrumb \uD56D\uBAA9 ${fatalCount}\uAC1C\uC5D0 \uD544\uC218 \uD544\uB4DC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.`,
1130
1190
  evidence: allIssues.slice(0, 5),
1131
1191
  fixHint: 'Each itemListElement needs { "@type": "ListItem", position: N, name, item }.',
1132
1192
  estimatedImpact: Math.round(2 * (1 - score))
@@ -1157,6 +1217,7 @@ var titleRule = defineRule({
1157
1217
  effort: "low",
1158
1218
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cittitle",
1159
1219
  title: "<title> is set with a reasonable length",
1220
+ title_ko: "<title> \uD0DC\uADF8 \uC801\uC815 \uAE38\uC774 \uC124\uC815 \uC5EC\uBD80",
1160
1221
  description: "The document title is the single most-cited piece of text and should be 10\u201370 characters.",
1161
1222
  run(ctx) {
1162
1223
  const title = ctx.$("head > title").first().text().trim();
@@ -1165,6 +1226,7 @@ var titleRule = defineRule({
1165
1226
  status: "fail",
1166
1227
  score: 0,
1167
1228
  rationale: "Page has no <title>.",
1229
+ rationale_ko: "\uD398\uC774\uC9C0\uC5D0 <title>\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.",
1168
1230
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1169
1231
  };
1170
1232
  }
@@ -1172,17 +1234,19 @@ var titleRule = defineRule({
1172
1234
  return {
1173
1235
  status: "warn",
1174
1236
  score: 0.4,
1175
- rationale: `Title is only ${title.length} chars; consider a more descriptive one.`
1237
+ rationale: `Title is only ${title.length} chars; consider a more descriptive one.`,
1238
+ 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.`
1176
1239
  };
1177
1240
  }
1178
1241
  if (title.length > 70) {
1179
1242
  return {
1180
1243
  status: "warn",
1181
1244
  score: 0.6,
1182
- rationale: `Title is ${title.length} chars; search UIs commonly truncate after ~70.`
1245
+ rationale: `Title is ${title.length} chars; search UIs commonly truncate after ~70.`,
1246
+ 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.`
1183
1247
  };
1184
1248
  }
1185
- return { status: "pass", score: 1, rationale: `Title length ${title.length} is within range.` };
1249
+ 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.` };
1186
1250
  }
1187
1251
  });
1188
1252
 
@@ -1197,6 +1261,7 @@ var metaDescriptionRule = defineRule({
1197
1261
  effort: "low",
1198
1262
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citmeta-description",
1199
1263
  title: "meta description is set (50\u2013160 chars)",
1264
+ title_ko: "meta description \uC124\uC815 \uC5EC\uBD80 (50\u2013160\uC790)",
1200
1265
  description: "AI snippets often quote the meta description verbatim; aim for 50\u2013160 chars.",
1201
1266
  run(ctx) {
1202
1267
  const desc = ctx.$('head meta[name="description"]').attr("content")?.trim() ?? "";
@@ -1205,16 +1270,17 @@ var metaDescriptionRule = defineRule({
1205
1270
  status: "warn",
1206
1271
  score: 0,
1207
1272
  rationale: "No meta description set.",
1273
+ rationale_ko: "meta description\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.",
1208
1274
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1209
1275
  };
1210
1276
  }
1211
1277
  if (desc.length < 50) {
1212
- return { status: "warn", score: 0.5, rationale: `Only ${desc.length} chars; aim for 50+.` };
1278
+ 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.` };
1213
1279
  }
1214
1280
  if (desc.length > 160) {
1215
- return { status: "warn", score: 0.7, rationale: `${desc.length} chars; may be truncated after 160.` };
1281
+ 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.` };
1216
1282
  }
1217
- return { status: "pass", score: 1, rationale: `Description length ${desc.length} is within range.` };
1283
+ 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.` };
1218
1284
  }
1219
1285
  });
1220
1286
 
@@ -1229,6 +1295,7 @@ var canonicalRule = defineRule({
1229
1295
  effort: "low",
1230
1296
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citcanonical",
1231
1297
  title: "Canonical URL is declared",
1298
+ title_ko: "Canonical URL \uC120\uC5B8 \uC5EC\uBD80",
1232
1299
  description: 'rel="canonical" tells crawlers which URL is the source of truth, preventing duplicate-citation confusion.',
1233
1300
  run(ctx) {
1234
1301
  const href = ctx.$('head link[rel="canonical"]').attr("href")?.trim();
@@ -1237,14 +1304,15 @@ var canonicalRule = defineRule({
1237
1304
  status: "warn",
1238
1305
  score: 0,
1239
1306
  rationale: 'No <link rel="canonical"> found.',
1307
+ rationale_ko: '<link rel="canonical">\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.',
1240
1308
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1241
1309
  };
1242
1310
  }
1243
1311
  try {
1244
1312
  const abs = new URL(href, ctx.finalUrl).toString();
1245
- return { status: "pass", score: 1, rationale: `Canonical URL: ${abs}.` };
1313
+ return { status: "pass", score: 1, rationale: `Canonical URL: ${abs}.`, rationale_ko: `Canonical URL: ${abs}.` };
1246
1314
  } catch {
1247
- return { status: "fail", score: 0, rationale: `Canonical href is not a valid URL: ${href}` };
1315
+ 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}` };
1248
1316
  }
1249
1317
  }
1250
1318
  });
@@ -1261,6 +1329,7 @@ var ogTagsRule = defineRule({
1261
1329
  effort: "low",
1262
1330
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citog-tags",
1263
1331
  title: "Open Graph tags are set",
1332
+ title_ko: "Open Graph \uD0DC\uADF8 \uC124\uC815 \uC5EC\uBD80",
1264
1333
  description: "og:title/type/url/image power rich previews on AI chat, social, and messaging.",
1265
1334
  run(ctx) {
1266
1335
  const missing = [];
@@ -1269,13 +1338,14 @@ var ogTagsRule = defineRule({
1269
1338
  if (!val) missing.push(prop);
1270
1339
  }
1271
1340
  if (missing.length === 0) {
1272
- return { status: "pass", score: 1, rationale: "All required OG tags present." };
1341
+ 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." };
1273
1342
  }
1274
1343
  const ratio = 1 - missing.length / REQUIRED.length;
1275
1344
  return {
1276
1345
  status: missing.length === REQUIRED.length ? "fail" : "warn",
1277
1346
  score: ratio,
1278
1347
  rationale: `Missing: ${missing.join(", ")}.`,
1348
+ rationale_ko: `\uB204\uB77D\uB41C \uD0DC\uADF8: ${missing.join(", ")}.`,
1279
1349
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1280
1350
  };
1281
1351
  }
@@ -1292,20 +1362,22 @@ var twitterCardRule = defineRule({
1292
1362
  effort: "low",
1293
1363
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cittwitter-card",
1294
1364
  title: "Twitter Card metadata is set",
1365
+ title_ko: "Twitter Card \uBA54\uD0C0\uB370\uC774\uD130 \uC124\uC815 \uC5EC\uBD80",
1295
1366
  description: "twitter:card + twitter:title give better previews on X/Twitter and some AI surfaces that reuse the tags.",
1296
1367
  run(ctx) {
1297
1368
  const card = ctx.$('head meta[name="twitter:card"]').attr("content")?.trim();
1298
1369
  const title = ctx.$('head meta[name="twitter:title"]').attr("content")?.trim();
1299
1370
  if (card && title) {
1300
- return { status: "pass", score: 1, rationale: `Card type: ${card}.` };
1371
+ return { status: "pass", score: 1, rationale: `Card type: ${card}.`, rationale_ko: `\uCE74\uB4DC \uC720\uD615: ${card}.` };
1301
1372
  }
1302
1373
  if (card || title) {
1303
- return { status: "warn", score: 0.5, rationale: "Partial twitter:* metadata; add the missing tag." };
1374
+ 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." };
1304
1375
  }
1305
1376
  return {
1306
1377
  status: "warn",
1307
1378
  score: 0,
1308
1379
  rationale: "No twitter:card metadata.",
1380
+ rationale_ko: "twitter:card \uBA54\uD0C0\uB370\uC774\uD130\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.",
1309
1381
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1310
1382
  };
1311
1383
  }
@@ -1322,6 +1394,7 @@ var langAttrRule = defineRule({
1322
1394
  effort: "low",
1323
1395
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citlang-attr",
1324
1396
  title: "<html lang> is set",
1397
+ title_ko: "<html lang> \uC18D\uC131 \uC124\uC815 \uC5EC\uBD80",
1325
1398
  description: "A lang attribute helps AI engines route the page to the right-language search surface (and helps screen readers).",
1326
1399
  run(ctx) {
1327
1400
  const lang = ctx.$("html").attr("lang")?.trim();
@@ -1330,10 +1403,11 @@ var langAttrRule = defineRule({
1330
1403
  status: "warn",
1331
1404
  score: 0,
1332
1405
  rationale: "No lang attribute on <html>.",
1406
+ rationale_ko: "<html>\uC5D0 lang \uC18D\uC131\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.",
1333
1407
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1334
1408
  };
1335
1409
  }
1336
- return { status: "pass", score: 1, rationale: `lang="${lang}".` };
1410
+ return { status: "pass", score: 1, rationale: `lang="${lang}".`, rationale_ko: `lang="${lang}".` };
1337
1411
  }
1338
1412
  });
1339
1413
 
@@ -1348,25 +1422,27 @@ var authorVisibleRule = defineRule({
1348
1422
  effort: "medium",
1349
1423
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citauthor-visible",
1350
1424
  title: "Author is declared",
1425
+ title_ko: "\uC791\uC131\uC790 \uC815\uBCF4 \uC120\uC5B8 \uC5EC\uBD80",
1351
1426
  description: "AI engines prefer citing content with an identifiable author; expose one via JSON-LD, meta[name=author], rel=author, or a .author class.",
1352
1427
  run(ctx) {
1353
1428
  for (const node of flattenJsonLd(ctx.jsonLd)) {
1354
1429
  if (hasField(node, "author")) {
1355
- return { status: "pass", score: 1, rationale: "Author found in JSON-LD." };
1430
+ 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." };
1356
1431
  }
1357
1432
  }
1358
1433
  const metaAuthor = ctx.$('head meta[name="author"]').attr("content")?.trim();
1359
- if (metaAuthor) return { status: "pass", score: 1, rationale: `meta[name=author] = "${metaAuthor}".` };
1434
+ if (metaAuthor) return { status: "pass", score: 1, rationale: `meta[name=author] = "${metaAuthor}".`, rationale_ko: `meta[name=author] = "${metaAuthor}".` };
1360
1435
  if (ctx.$('[rel="author"]').length > 0) {
1361
- return { status: "pass", score: 1, rationale: 'rel="author" link found.' };
1436
+ return { status: "pass", score: 1, rationale: 'rel="author" link found.', rationale_ko: 'rel="author" \uB9C1\uD06C\uB97C \uCC3E\uC558\uC2B5\uB2C8\uB2E4.' };
1362
1437
  }
1363
1438
  if (ctx.$('.author, [class*="author"], [itemprop="author"]').length > 0) {
1364
- return { status: "pass", score: 0.8, rationale: "Author-ish DOM selector found (weaker signal)." };
1439
+ 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)." };
1365
1440
  }
1366
1441
  return {
1367
1442
  status: "warn",
1368
1443
  score: 0,
1369
1444
  rationale: "No author signal found (JSON-LD, meta, rel, or .author).",
1445
+ rationale_ko: "\uC791\uC131\uC790 \uC815\uBCF4\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4 (JSON-LD, meta, rel, .author \uBAA8\uB450 \uC5C6\uC74C).",
1370
1446
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1371
1447
  };
1372
1448
  }
@@ -1383,25 +1459,27 @@ var datesRule = defineRule({
1383
1459
  effort: "low",
1384
1460
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citdates",
1385
1461
  title: "Publish / modified date is present",
1462
+ title_ko: "\uBC1C\uD589\uC77C / \uC218\uC815\uC77C \uC874\uC7AC \uC5EC\uBD80",
1386
1463
  description: "AI engines rank recent pages higher; expose datePublished via JSON-LD, <time datetime>, or article:published_time meta.",
1387
1464
  run(ctx) {
1388
1465
  for (const node of flattenJsonLd(ctx.jsonLd)) {
1389
1466
  if (hasField(node, "datePublished")) {
1390
- return { status: "pass", score: 1, rationale: "datePublished found in JSON-LD." };
1467
+ return { status: "pass", score: 1, rationale: "datePublished found in JSON-LD.", rationale_ko: "JSON-LD\uC5D0\uC11C datePublished\uB97C \uCC3E\uC558\uC2B5\uB2C8\uB2E4." };
1391
1468
  }
1392
1469
  }
1393
1470
  const articleTime = ctx.$('head meta[property="article:published_time"]').attr("content")?.trim();
1394
1471
  if (articleTime) {
1395
- return { status: "pass", score: 1, rationale: `article:published_time = ${articleTime}.` };
1472
+ return { status: "pass", score: 1, rationale: `article:published_time = ${articleTime}.`, rationale_ko: `article:published_time = ${articleTime}.` };
1396
1473
  }
1397
1474
  const timeEl = ctx.$("time[datetime]").first().attr("datetime")?.trim();
1398
1475
  if (timeEl) {
1399
- return { status: "pass", score: 0.8, rationale: `<time datetime="${timeEl}"> found.` };
1476
+ return { status: "pass", score: 0.8, rationale: `<time datetime="${timeEl}"> found.`, rationale_ko: `<time datetime="${timeEl}">\uB97C \uCC3E\uC558\uC2B5\uB2C8\uB2E4.` };
1400
1477
  }
1401
1478
  return {
1402
1479
  status: "warn",
1403
1480
  score: 0,
1404
1481
  rationale: "No publish date found (JSON-LD, meta article:published_time, or <time datetime>).",
1482
+ rationale_ko: "\uBC1C\uD589\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4 (JSON-LD, meta article:published_time, <time datetime> \uBAA8\uB450 \uC5C6\uC74C).",
1405
1483
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1406
1484
  };
1407
1485
  }
@@ -1429,6 +1507,7 @@ var contentFreshnessRule = defineRule({
1429
1507
  effort: "low",
1430
1508
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citcontent-freshness",
1431
1509
  title: "Article content is fresh (dateModified within 1 year)",
1510
+ title_ko: "\uCF58\uD150\uCE20 \uCD5C\uC2E0\uC131 (dateModified 1\uB144 \uC774\uB0B4)",
1432
1511
  description: "AI engines down-rank stale content. Surface a recent dateModified (\u2264365 days) on Article-like pages so retrieval rankings stay strong.",
1433
1512
  run(ctx) {
1434
1513
  const nodes = flattenJsonLd(ctx.jsonLd);
@@ -1437,7 +1516,8 @@ var contentFreshnessRule = defineRule({
1437
1516
  return {
1438
1517
  status: "skip",
1439
1518
  score: 0,
1440
- rationale: "No Article/BlogPosting/NewsArticle JSON-LD; freshness signal not applicable."
1519
+ rationale: "No Article/BlogPosting/NewsArticle JSON-LD; freshness signal not applicable.",
1520
+ rationale_ko: "Article/BlogPosting/NewsArticle JSON-LD\uAC00 \uC5C6\uC5B4 \uCD5C\uC2E0\uC131 \uC2E0\uD638\uB97C \uD655\uC778\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4."
1441
1521
  };
1442
1522
  }
1443
1523
  let bestMs = null;
@@ -1459,6 +1539,7 @@ var contentFreshnessRule = defineRule({
1459
1539
  status: "warn",
1460
1540
  score: 0,
1461
1541
  rationale: "Article has no parseable dateModified or datePublished.",
1542
+ rationale_ko: "Article JSON-LD\uC5D0 \uD30C\uC2F1 \uAC00\uB2A5\uD55C dateModified \uB610\uB294 datePublished\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.",
1462
1543
  fixHint: "Add ISO-8601 dateModified and datePublished to your Article JSON-LD.",
1463
1544
  estimatedImpact: 3
1464
1545
  };
@@ -1469,6 +1550,7 @@ var contentFreshnessRule = defineRule({
1469
1550
  status: "pass",
1470
1551
  score: 1,
1471
1552
  rationale: `${usedField} within the last year (~${ageDays} day${ageDays === 1 ? "" : "s"} ago).`,
1553
+ rationale_ko: `${usedField}\uC774 1\uB144 \uC774\uB0B4\uC785\uB2C8\uB2E4 (\uC57D ${ageDays}\uC77C \uC804).`,
1472
1554
  evidence: { ageDays, field: usedField }
1473
1555
  };
1474
1556
  }
@@ -1477,6 +1559,7 @@ var contentFreshnessRule = defineRule({
1477
1559
  status: "warn",
1478
1560
  score: 0.6,
1479
1561
  rationale: `${usedField} is ${ageDays} days old. Refresh within a year for best AI ranking.`,
1562
+ 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.`,
1480
1563
  evidence: { ageDays, field: usedField },
1481
1564
  estimatedImpact: 2
1482
1565
  };
@@ -1485,6 +1568,7 @@ var contentFreshnessRule = defineRule({
1485
1568
  status: "warn",
1486
1569
  score: 0.2,
1487
1570
  rationale: `${usedField} is ${ageDays} days old (>2 years). AI engines treat this as stale.`,
1571
+ 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.`,
1488
1572
  evidence: { ageDays, field: usedField },
1489
1573
  fixHint: "Update content and bump dateModified to today's date.",
1490
1574
  estimatedImpact: 3
@@ -1516,22 +1600,25 @@ var singleH1Rule = defineRule({
1516
1600
  effort: "low",
1517
1601
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntsingle-h1",
1518
1602
  title: "Exactly one <h1>",
1603
+ title_ko: "<h1> \uD0DC\uADF8 1\uAC1C \uC5EC\uBD80",
1519
1604
  description: "A single H1 tells AI engines the primary topic of the page without ambiguity.",
1520
1605
  run(ctx) {
1521
1606
  const n = ctx.$("h1").length;
1522
- if (n === 1) return { status: "pass", score: 1, rationale: "Exactly one <h1>." };
1607
+ if (n === 1) return { status: "pass", score: 1, rationale: "Exactly one <h1>.", rationale_ko: "<h1>\uC774 \uC815\uD655\uD788 1\uAC1C\uC785\uB2C8\uB2E4." };
1523
1608
  if (n === 0) {
1524
1609
  return {
1525
1610
  status: "fail",
1526
1611
  score: 0,
1527
1612
  rationale: "No <h1> on the page.",
1613
+ rationale_ko: "\uD398\uC774\uC9C0\uC5D0 <h1>\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.",
1528
1614
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules/cnt.single-h1.md"
1529
1615
  };
1530
1616
  }
1531
1617
  return {
1532
1618
  status: "warn",
1533
1619
  score: Math.max(0.3, 1 / n),
1534
- rationale: `Found ${n} <h1> tags; prefer one primary heading.`
1620
+ rationale: `Found ${n} <h1> tags; prefer one primary heading.`,
1621
+ rationale_ko: `<h1>\uC774 ${n}\uAC1C \uC788\uC2B5\uB2C8\uB2E4. \uB300\uD45C \uC81C\uBAA9 1\uAC1C\uB9CC \uC0AC\uC6A9\uD558\uC138\uC694.`
1535
1622
  };
1536
1623
  }
1537
1624
  });
@@ -1547,6 +1634,7 @@ var headingHierarchyRule = defineRule({
1547
1634
  effort: "medium",
1548
1635
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntheading-hierarchy",
1549
1636
  title: "Heading levels do not skip",
1637
+ title_ko: "\uC81C\uBAA9 \uB2E8\uACC4 \uC21C\uC11C \uC900\uC218 \uC5EC\uBD80",
1550
1638
  description: "Going from H2 directly to H4 breaks the outline AI engines use to segment content.",
1551
1639
  run(ctx) {
1552
1640
  const levels = [];
@@ -1556,7 +1644,7 @@ var headingHierarchyRule = defineRule({
1556
1644
  if (m?.[1]) levels.push(parseInt(m[1], 10));
1557
1645
  });
1558
1646
  if (levels.length === 0) {
1559
- return { status: "skip", score: 0, rationale: "No headings found." };
1647
+ return { status: "skip", score: 0, rationale: "No headings found.", rationale_ko: "\uC81C\uBAA9 \uD0DC\uADF8\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
1560
1648
  }
1561
1649
  const skips = [];
1562
1650
  for (let i = 1; i < levels.length; i++) {
@@ -1565,12 +1653,13 @@ var headingHierarchyRule = defineRule({
1565
1653
  if (curr > prev + 1) skips.push({ from: prev, to: curr });
1566
1654
  }
1567
1655
  if (skips.length === 0) {
1568
- return { status: "pass", score: 1, rationale: "No heading-level skips." };
1656
+ 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." };
1569
1657
  }
1570
1658
  return {
1571
1659
  status: "warn",
1572
1660
  score: Math.max(0.3, 1 - skips.length / levels.length),
1573
1661
  rationale: `${skips.length} heading skip(s) detected (e.g. h${skips[0].from}\u2192h${skips[0].to}).`,
1662
+ 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}).`,
1574
1663
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1575
1664
  };
1576
1665
  }
@@ -1587,11 +1676,12 @@ var imageAltRule = defineRule({
1587
1676
  effort: "medium",
1588
1677
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntimage-alt",
1589
1678
  title: "\u226580% of <img> have alt text",
1679
+ title_ko: "<img>\uC758 80% \uC774\uC0C1 alt \uD14D\uC2A4\uD2B8 \uBCF4\uC720 \uC5EC\uBD80",
1590
1680
  description: "Alt text gives AI engines a textual anchor for visual content and improves accessibility.",
1591
1681
  run(ctx) {
1592
1682
  const imgs = ctx.$("img");
1593
1683
  const total = imgs.length;
1594
- if (total === 0) return { status: "skip", score: 0, rationale: "No <img> on the page." };
1684
+ 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." };
1595
1685
  let withAlt = 0;
1596
1686
  imgs.each((_i, el) => {
1597
1687
  const alt = ctx.$(el).attr("alt");
@@ -1599,12 +1689,13 @@ var imageAltRule = defineRule({
1599
1689
  });
1600
1690
  const ratio = withAlt / total;
1601
1691
  if (ratio >= 0.8) {
1602
- return { status: "pass", score: 1, rationale: `${withAlt}/${total} images have alt (${Math.round(ratio * 100)}%).` };
1692
+ 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)}%).` };
1603
1693
  }
1604
1694
  return {
1605
1695
  status: "warn",
1606
1696
  score: ratio,
1607
1697
  rationale: `Only ${withAlt}/${total} images have alt text (${Math.round(ratio * 100)}%). Aim for \u226580%.`,
1698
+ 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.`,
1608
1699
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1609
1700
  };
1610
1701
  }
@@ -1621,11 +1712,12 @@ var tldrOrFaqRule = defineRule({
1621
1712
  effort: "medium",
1622
1713
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cnttldr-or-faq",
1623
1714
  title: "TL;DR summary or FAQ block",
1715
+ title_ko: "TL;DR \uC694\uC57D \uB610\uB294 FAQ \uBE14\uB85D \uC874\uC7AC \uC5EC\uBD80",
1624
1716
  description: 'AI engines strongly prefer content with a quotable summary or FAQ \u2014 it makes the page "citation-ready".',
1625
1717
  run(ctx) {
1626
1718
  for (const node of flattenJsonLd(ctx.jsonLd)) {
1627
1719
  if (getTypes(node).includes("FAQPage")) {
1628
- return { status: "pass", score: 1, rationale: "FAQPage schema present." };
1720
+ return { status: "pass", score: 1, rationale: "FAQPage schema present.", rationale_ko: "FAQPage \uC2A4\uD0A4\uB9C8\uAC00 \uC788\uC2B5\uB2C8\uB2E4." };
1629
1721
  }
1630
1722
  }
1631
1723
  const sel = [
@@ -1638,12 +1730,13 @@ var tldrOrFaqRule = defineRule({
1638
1730
  "[data-tldr]"
1639
1731
  ].join(", ");
1640
1732
  if (ctx.$(sel).length > 0) {
1641
- return { status: "pass", score: 0.85, rationale: "TL;DR / summary / FAQ region detected by selector." };
1733
+ 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." };
1642
1734
  }
1643
1735
  return {
1644
1736
  status: "warn",
1645
1737
  score: 0,
1646
1738
  rationale: "No TL;DR / summary / FAQ found; add one to boost AI citation odds.",
1739
+ 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.",
1647
1740
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1648
1741
  };
1649
1742
  }
@@ -1660,6 +1753,7 @@ var wordCountRule = defineRule({
1660
1753
  effort: "high",
1661
1754
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntword-count",
1662
1755
  title: "Page has enough body text",
1756
+ title_ko: "\uCDA9\uBD84\uD55C \uBCF8\uBB38 \uD14D\uC2A4\uD2B8 \uC5EC\uBD80",
1663
1757
  description: "Thin pages (under ~100 words) are rarely cited by AI engines. Aim for \u2265300 words of meaningful body copy.",
1664
1758
  run(ctx) {
1665
1759
  const $ = ctx.$;
@@ -1667,12 +1761,13 @@ var wordCountRule = defineRule({
1667
1761
  clone.find("script, style, noscript, nav, header, footer, aside").remove();
1668
1762
  const text = clone.text().replace(/\s+/g, " ").trim();
1669
1763
  const words = text ? text.split(" ").length : 0;
1670
- if (words >= 300) return { status: "pass", score: 1, rationale: `${words} words of body text.` };
1671
- if (words >= 100) return { status: "warn", score: 0.5, rationale: `Only ${words} words; aim for 300+.` };
1764
+ 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.` };
1765
+ 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.` };
1672
1766
  return {
1673
1767
  status: "fail",
1674
1768
  score: 0,
1675
1769
  rationale: `Only ${words} words; too thin to be cited.`,
1770
+ 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.`,
1676
1771
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1677
1772
  };
1678
1773
  }
@@ -1723,11 +1818,12 @@ var qaStructureRule = defineRule({
1723
1818
  effort: "medium",
1724
1819
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntqa-structure",
1725
1820
  title: "Content uses Q&A structure for answer extraction",
1821
+ title_ko: "\uB2F5\uBCC0 \uCD94\uCD9C\uC744 \uC704\uD55C Q&A \uAD6C\uC870 \uC0AC\uC6A9 \uC5EC\uBD80",
1726
1822
  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.",
1727
1823
  run(ctx) {
1728
1824
  for (const node of flattenJsonLd(ctx.jsonLd)) {
1729
1825
  if (getTypes(node).includes("FAQPage")) {
1730
- return { status: "pass", score: 1, rationale: "FAQPage JSON-LD provides explicit Q&A." };
1826
+ 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." };
1731
1827
  }
1732
1828
  }
1733
1829
  const questionHeadings = [];
@@ -1740,6 +1836,7 @@ var qaStructureRule = defineRule({
1740
1836
  status: "pass",
1741
1837
  score: 1,
1742
1838
  rationale: `${questionHeadings.length} question-style headings detected.`,
1839
+ rationale_ko: `\uC9C8\uBB38\uD615 \uC81C\uBAA9\uC774 ${questionHeadings.length}\uAC1C \uAC10\uC9C0\uB429\uB2C8\uB2E4.`,
1743
1840
  evidence: { headings: questionHeadings.slice(0, 5) }
1744
1841
  };
1745
1842
  }
@@ -1748,6 +1845,7 @@ var qaStructureRule = defineRule({
1748
1845
  status: "warn",
1749
1846
  score: 0.6,
1750
1847
  rationale: "1 question-style heading. Add a second to strengthen answer extraction.",
1848
+ 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.",
1751
1849
  evidence: { headings: questionHeadings },
1752
1850
  estimatedImpact: 1
1753
1851
  };
@@ -1756,6 +1854,7 @@ var qaStructureRule = defineRule({
1756
1854
  status: "warn",
1757
1855
  score: 0,
1758
1856
  rationale: "No question-style H2/H3 headings or FAQPage JSON-LD found.",
1857
+ rationale_ko: "\uC9C8\uBB38\uD615 H2/H3 \uC81C\uBAA9\uC774\uB098 FAQPage JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.",
1759
1858
  fixHint: 'Reframe at least 2 H2 headings as questions ("How do I\u2026?", "What is\u2026?") or add FAQPage JSON-LD.',
1760
1859
  estimatedImpact: 3
1761
1860
  };
@@ -1773,13 +1872,14 @@ var externalCitationsRule = defineRule({
1773
1872
  effort: "medium",
1774
1873
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntexternal-citations",
1775
1874
  title: "Content cites external sources",
1875
+ title_ko: "\uC678\uBD80 \uCD9C\uCC98 \uC778\uC6A9 \uC5EC\uBD80",
1776
1876
  description: "Outbound links to authoritative external sources are an E-E-A-T trust signal. AI engines treat well-cited pages as more credible.",
1777
1877
  run(ctx) {
1778
1878
  let host;
1779
1879
  try {
1780
1880
  host = new URL(ctx.finalUrl).hostname.toLowerCase();
1781
1881
  } catch {
1782
- return { status: "skip", score: 0, rationale: "Invalid finalUrl." };
1882
+ return { status: "skip", score: 0, rationale: "Invalid finalUrl.", rationale_ko: "\uC720\uD6A8\uD558\uC9C0 \uC54A\uC740 finalUrl\uC785\uB2C8\uB2E4." };
1783
1883
  }
1784
1884
  const seen = /* @__PURE__ */ new Set();
1785
1885
  ctx.$("main a[href], article a[href], body a[href]").each((_i, el) => {
@@ -1804,6 +1904,7 @@ var externalCitationsRule = defineRule({
1804
1904
  status: "pass",
1805
1905
  score: 1,
1806
1906
  rationale: `${count} distinct external host(s) cited (excluding nofollow).`,
1907
+ rationale_ko: `\uC678\uBD80 \uC0AC\uC774\uD2B8 ${count}\uAC1C\uB97C \uC778\uC6A9\uD569\uB2C8\uB2E4 (nofollow \uC81C\uC678).`,
1807
1908
  evidence: { hosts: [...seen].slice(0, 8) }
1808
1909
  };
1809
1910
  }
@@ -1812,6 +1913,7 @@ var externalCitationsRule = defineRule({
1812
1913
  status: "pass",
1813
1914
  score: 0.7,
1814
1915
  rationale: `${count} external host(s) cited. Aim for \u22653 for stronger E-E-A-T.`,
1916
+ 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.`,
1815
1917
  evidence: { hosts: [...seen] },
1816
1918
  estimatedImpact: 1
1817
1919
  };
@@ -1820,6 +1922,7 @@ var externalCitationsRule = defineRule({
1820
1922
  status: "warn",
1821
1923
  score: 0,
1822
1924
  rationale: "No external follow citations found in main content.",
1925
+ rationale_ko: "\uBCF8\uBB38\uC5D0 \uC678\uBD80 \uCD9C\uCC98 \uB9C1\uD06C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.",
1823
1926
  fixHint: "Cite at least one authoritative external source (research paper, official docs, news outlet).",
1824
1927
  estimatedImpact: 2
1825
1928
  };
@@ -1837,12 +1940,130 @@ var contentRules = [
1837
1940
  externalCitationsRule
1838
1941
  ];
1839
1942
 
1943
+ // src/rules/aeo/skill-md.ts
1944
+ var aeoSkillMdRule = defineRule({
1945
+ id: "aeo.skill-md",
1946
+ stableId: "aeo.skill-md",
1947
+ category: "aeo",
1948
+ group: "opportunity",
1949
+ weight: 3,
1950
+ impact: "high",
1951
+ effort: "low",
1952
+ docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#aeoskill-md",
1953
+ title: "skill.md is present",
1954
+ title_ko: "skill.md \uD30C\uC77C \uC874\uC7AC \uC5EC\uBD80",
1955
+ description: "A /skill.md file describes site capabilities so AI agents know what this site can do for them.",
1956
+ run(ctx) {
1957
+ if (ctx.skillMd !== null) {
1958
+ return {
1959
+ status: "pass",
1960
+ score: 1,
1961
+ rationale: "skill.md found at site root.",
1962
+ rationale_ko: "skill.md\uAC00 \uC0AC\uC774\uD2B8 \uB8E8\uD2B8\uC5D0 \uC874\uC7AC\uD569\uB2C8\uB2E4."
1963
+ };
1964
+ }
1965
+ return {
1966
+ status: "warn",
1967
+ score: 0,
1968
+ rationale: "No /skill.md found. Add one to describe your site capabilities to AI agents.",
1969
+ 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.",
1970
+ fixHint: "Create /skill.md listing what services, products, and capabilities this site offers."
1971
+ };
1972
+ }
1973
+ });
1974
+
1975
+ // src/rules/aeo/agent-permissions.ts
1976
+ var aeoAgentPermissionsRule = defineRule({
1977
+ id: "aeo.agent-permissions",
1978
+ stableId: "aeo.agent-permissions",
1979
+ category: "aeo",
1980
+ group: "opportunity",
1981
+ weight: 3,
1982
+ impact: "medium",
1983
+ effort: "low",
1984
+ docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#aeoagent-permissions",
1985
+ title: "agent-permissions.json is present",
1986
+ title_ko: "agent-permissions.json \uD30C\uC77C \uC874\uC7AC \uC5EC\uBD80",
1987
+ description: "Declares explicit read/summarize/cite/train permissions for AI agents.",
1988
+ run(ctx) {
1989
+ if (ctx.agentPermissions !== null) {
1990
+ return {
1991
+ status: "pass",
1992
+ score: 1,
1993
+ rationale: "agent-permissions.json found at site root.",
1994
+ rationale_ko: "agent-permissions.json\uC774 \uC0AC\uC774\uD2B8 \uB8E8\uD2B8\uC5D0 \uC874\uC7AC\uD569\uB2C8\uB2E4.",
1995
+ evidence: ctx.agentPermissions
1996
+ };
1997
+ }
1998
+ return {
1999
+ status: "warn",
2000
+ score: 0,
2001
+ rationale: "No /agent-permissions.json found. Add one to declare AI agent access policy.",
2002
+ 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.",
2003
+ fixHint: "Create /agent-permissions.json with read, summarize, cite, and train permission flags."
2004
+ };
2005
+ }
2006
+ });
2007
+
2008
+ // src/rules/aeo/token-length.ts
2009
+ var THRESHOLD_OPTIMAL = 15e3;
2010
+ var THRESHOLD_MAX = 25e3;
2011
+ var aeoTokenLengthRule = defineRule({
2012
+ id: "aeo.token-length",
2013
+ stableId: "aeo.token-length",
2014
+ category: "aeo",
2015
+ group: "diagnostic",
2016
+ weight: 4,
2017
+ impact: "medium",
2018
+ effort: "medium",
2019
+ docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#aeotoken-length",
2020
+ title: "Content token length within AI agent limits",
2021
+ title_ko: "\uCF58\uD150\uCE20 \uD1A0\uD070 \uC218 AI \uC5D0\uC774\uC804\uD2B8 \uAD8C\uC7A5 \uBC94\uC704",
2022
+ description: "Pages under 15K tokens are optimal for AI agents (per Addy Osmani's AEO guidance).",
2023
+ run(ctx) {
2024
+ const text = ctx.$("body").text();
2025
+ const tokenEstimate = Math.round(text.length / 3);
2026
+ const evidence = { tokenEstimate, thresholds: { optimal: THRESHOLD_OPTIMAL, max: THRESHOLD_MAX } };
2027
+ if (tokenEstimate <= THRESHOLD_OPTIMAL) {
2028
+ return {
2029
+ status: "pass",
2030
+ score: 1,
2031
+ rationale: `Estimated ~${tokenEstimate.toLocaleString()} tokens \u2014 within optimal range.`,
2032
+ rationale_ko: `\uC608\uC0C1 \uD1A0\uD070 \uC218 ~${tokenEstimate.toLocaleString()} \u2014 \uAD8C\uC7A5 \uBC94\uC704(15K) \uC774\uB0B4\uC785\uB2C8\uB2E4.`,
2033
+ evidence
2034
+ };
2035
+ }
2036
+ if (tokenEstimate <= THRESHOLD_MAX) {
2037
+ return {
2038
+ status: "warn",
2039
+ score: 0.5,
2040
+ rationale: `Estimated ~${tokenEstimate.toLocaleString()} tokens \u2014 exceeds 15K recommendation.`,
2041
+ rationale_ko: `\uC608\uC0C1 \uD1A0\uD070 \uC218 ~${tokenEstimate.toLocaleString()} \u2014 15K \uAD8C\uC7A5\uCE58\uB97C \uCD08\uACFC\uD569\uB2C8\uB2E4.`,
2042
+ fixHint: "Consider splitting content into shorter, focused pages.",
2043
+ evidence
2044
+ };
2045
+ }
2046
+ return {
2047
+ status: "fail",
2048
+ score: 0,
2049
+ rationale: `Estimated ~${tokenEstimate.toLocaleString()} tokens \u2014 exceeds 25K agent processing limit.`,
2050
+ 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.`,
2051
+ fixHint: "Split this page into multiple focused pages under 15K tokens.",
2052
+ evidence
2053
+ };
2054
+ }
2055
+ });
2056
+
2057
+ // src/rules/aeo/index.ts
2058
+ var aeoRules = [aeoSkillMdRule, aeoAgentPermissionsRule, aeoTokenLengthRule];
2059
+
1840
2060
  // src/rules/index.ts
1841
2061
  var defaultRules = [
1842
2062
  ...crawlerRules,
1843
2063
  ...structuredDataRules,
1844
2064
  ...citationRules,
1845
- ...contentRules
2065
+ ...contentRules,
2066
+ ...aeoRules
1846
2067
  ];
1847
2068
 
1848
2069
  // src/config.ts
@@ -2038,7 +2259,8 @@ var CATEGORY_LABELS = {
2038
2259
  crawler: "AI Crawler Access",
2039
2260
  "structured-data": "Structured Data",
2040
2261
  citation: "Citation Signals",
2041
- content: "Content Structure"
2262
+ content: "Content Structure",
2263
+ aeo: "AEO Stack"
2042
2264
  };
2043
2265
  function scoreBadge(score) {
2044
2266
  const color = score >= 85 ? "brightgreen" : score >= 60 ? "yellow" : "red";
@@ -2333,7 +2555,8 @@ var CATEGORY_LABELS2 = {
2333
2555
  crawler: "AI Crawler Access",
2334
2556
  "structured-data": "Structured Data",
2335
2557
  citation: "Citation Signals",
2336
- content: "Content Structure"
2558
+ content: "Content Structure",
2559
+ aeo: "AEO Stack"
2337
2560
  };
2338
2561
  function colorScore(score) {
2339
2562
  if (score >= 85) return import_kleur.default.green().bold(`${score}`);
@@ -2433,7 +2656,8 @@ var CATEGORY_LABELS3 = {
2433
2656
  crawler: "AI Crawler Access",
2434
2657
  "structured-data": "Structured Data",
2435
2658
  citation: "Citation Signals",
2436
- content: "Content Structure"
2659
+ content: "Content Structure",
2660
+ aeo: "AEO Stack"
2437
2661
  };
2438
2662
  var IMPACT_ORDER = {
2439
2663
  critical: 4,
@@ -2516,13 +2740,15 @@ function partitionResults(report) {
2516
2740
  crawler: [],
2517
2741
  "structured-data": [],
2518
2742
  citation: [],
2519
- content: []
2743
+ content: [],
2744
+ aeo: []
2520
2745
  };
2521
2746
  const passed = {
2522
2747
  crawler: [],
2523
2748
  "structured-data": [],
2524
2749
  citation: [],
2525
- content: []
2750
+ content: [],
2751
+ aeo: []
2526
2752
  };
2527
2753
  for (const cat of Object.keys(report.categories)) {
2528
2754
  for (const r of report.categories[cat].results) {