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.js CHANGED
@@ -263,10 +263,12 @@ async function buildContext(url, opts = {}) {
263
263
  "Site appears to be JS-rendered (sparse body + SPA root element). Re-run with --render for accurate results."
264
264
  );
265
265
  }
266
- const [robotsRaw, llmsRaw, llmsFullRaw] = await Promise.all([
266
+ const [robotsRaw, llmsRaw, llmsFullRaw, skillMdRaw, agentPermissionsRaw] = await Promise.all([
267
267
  fetchText(`${origin}/robots.txt`, opts),
268
268
  fetchText(`${origin}/llms.txt`, opts),
269
- fetchText(`${origin}/llms-full.txt`, opts)
269
+ fetchText(`${origin}/llms-full.txt`, opts),
270
+ fetchText(`${origin}/skill.md`, opts),
271
+ fetchText(`${origin}/agent-permissions.json`, opts)
270
272
  ]);
271
273
  let sitemapUrl = null;
272
274
  const robots = robotsRaw ? parseRobots(robotsRaw) : null;
@@ -274,6 +276,13 @@ async function buildContext(url, opts = {}) {
274
276
  if (!sitemapUrl) sitemapUrl = `${origin}/sitemap.xml`;
275
277
  const sitemapRaw = await fetchText(sitemapUrl, opts);
276
278
  const sitemap = sitemapRaw ? parseSitemap(sitemapRaw) : null;
279
+ let agentPermissions = null;
280
+ if (agentPermissionsRaw && agentPermissionsRaw.trim().length > 0) {
281
+ try {
282
+ agentPermissions = JSON.parse(agentPermissionsRaw);
283
+ } catch {
284
+ }
285
+ }
277
286
  return {
278
287
  url,
279
288
  finalUrl,
@@ -288,7 +297,9 @@ async function buildContext(url, opts = {}) {
288
297
  jsonLd: extractJsonLd($),
289
298
  renderMode,
290
299
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
291
- warnings
300
+ warnings,
301
+ skillMd: skillMdRaw && skillMdRaw.trim().length > 0 ? skillMdRaw : null,
302
+ agentPermissions
292
303
  };
293
304
  }
294
305
 
@@ -297,10 +308,11 @@ function defineRule(rule) {
297
308
  return rule;
298
309
  }
299
310
  var CATEGORY_WEIGHTS = {
300
- crawler: 25,
301
- "structured-data": 30,
302
- citation: 25,
303
- content: 20
311
+ crawler: 20,
312
+ "structured-data": 25,
313
+ citation: 20,
314
+ content: 15,
315
+ aeo: 20
304
316
  };
305
317
 
306
318
  // src/engine.ts
@@ -314,7 +326,8 @@ async function runRules(ctx, rules, opts = {}) {
314
326
  crawler: { score: 0, weight: weights.crawler, results: [] },
315
327
  "structured-data": { score: 0, weight: weights["structured-data"], results: [] },
316
328
  citation: { score: 0, weight: weights.citation, results: [] },
317
- content: { score: 0, weight: weights.content, results: [] }
329
+ content: { score: 0, weight: weights.content, results: [] },
330
+ aeo: { score: 0, weight: weights.aeo, results: [] }
318
331
  };
319
332
  for (const rule of rules) {
320
333
  if (onlySet && !onlySet.has(rule.id) && (!rule.stableId || !onlySet.has(rule.stableId))) continue;
@@ -339,6 +352,7 @@ async function runRules(ctx, rules, opts = {}) {
339
352
  durationMs
340
353
  };
341
354
  if (rule.stableId !== void 0) entry.stableId = rule.stableId;
355
+ if (rule.title_ko !== void 0) entry.title_ko = rule.title_ko;
342
356
  if (rule.group !== void 0) entry.group = rule.group;
343
357
  if (rule.impact !== void 0) entry.impact = rule.impact;
344
358
  if (rule.effort !== void 0) entry.effort = rule.effort;
@@ -401,13 +415,15 @@ var httpsRule = defineRule({
401
415
  effort: "medium",
402
416
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerhttps",
403
417
  title: "Site is served over HTTPS",
418
+ title_ko: "\uC0AC\uC774\uD2B8\uAC00 HTTPS\uB85C \uC81C\uACF5\uB428",
404
419
  description: "AI crawlers treat HTTPS pages as more trustworthy and some skip plain HTTP entirely.",
405
420
  run(ctx) {
406
421
  const isHttps = ctx.finalUrl.startsWith("https://");
407
- return isHttps ? { status: "pass", score: 1, rationale: "Final URL uses HTTPS." } : {
422
+ return isHttps ? { status: "pass", score: 1, rationale: "Final URL uses HTTPS.", rationale_ko: "\uCD5C\uC885 URL\uC774 HTTPS\uB97C \uC0AC\uC6A9\uD569\uB2C8\uB2E4." } : {
408
423
  status: "fail",
409
424
  score: 0,
410
425
  rationale: "Final URL does not use HTTPS. Redirect HTTP \u2192 HTTPS site-wide.",
426
+ 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.",
411
427
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
412
428
  };
413
429
  }
@@ -424,15 +440,17 @@ var robotsReachableRule = defineRule({
424
440
  effort: "low",
425
441
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerrobots-reachable",
426
442
  title: "robots.txt is reachable",
443
+ title_ko: "robots.txt \uC811\uADFC \uAC00\uB2A5 \uC5EC\uBD80",
427
444
  description: "A reachable robots.txt lets crawlers confirm their permissions; missing file is treated as allow-all but blocks explicit signalling.",
428
445
  run(ctx) {
429
446
  if (ctx.robots) {
430
- return { status: "pass", score: 1, rationale: "robots.txt returned successfully." };
447
+ 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." };
431
448
  }
432
449
  return {
433
450
  status: "warn",
434
451
  score: 0.3,
435
452
  rationale: "robots.txt is missing. Add one even if empty to explicitly signal crawl policy.",
453
+ 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.",
436
454
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
437
455
  };
438
456
  }
@@ -480,13 +498,15 @@ var robotsAiAllowRule = defineRule({
480
498
  effort: "low",
481
499
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerrobots-ai-allow",
482
500
  title: "AI crawlers are allowed",
501
+ title_ko: "AI \uD06C\uB864\uB7EC \uC811\uADFC \uD5C8\uC6A9 \uC5EC\uBD80",
483
502
  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.",
484
503
  run(ctx) {
485
504
  if (!ctx.robots) {
486
505
  return {
487
506
  status: "warn",
488
507
  score: 0.5,
489
- rationale: "robots.txt missing; AI crawlers default to allow, but explicit allow is recommended."
508
+ rationale: "robots.txt missing; AI crawlers default to allow, but explicit allow is recommended.",
509
+ 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."
490
510
  };
491
511
  }
492
512
  const path = new URL(ctx.finalUrl).pathname || "/";
@@ -504,6 +524,7 @@ var robotsAiAllowRule = defineRule({
504
524
  status: "fail",
505
525
  score: Math.max(0, 1 - blocked.length / AI_BOTS.length),
506
526
  rationale: `Blocked: ${blocked.join(", ")}. Remove the Disallow or add an explicit Allow for these user-agents.`,
527
+ 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.`,
507
528
  evidence: { blocked, mentioned, totalBots: AI_BOTS.length },
508
529
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
509
530
  };
@@ -512,13 +533,15 @@ var robotsAiAllowRule = defineRule({
512
533
  return {
513
534
  status: "warn",
514
535
  score: 0.6,
515
- rationale: `All ${AI_BOTS.length} AI crawlers reach the page via default rules, but none are explicitly listed. Consider explicit Allow entries.`
536
+ rationale: `All ${AI_BOTS.length} AI crawlers reach the page via default rules, but none are explicitly listed. Consider explicit Allow entries.`,
537
+ 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.`
516
538
  };
517
539
  }
518
540
  return {
519
541
  status: "pass",
520
542
  score: 1,
521
543
  rationale: `All ${AI_BOTS.length} AI crawlers can reach the page; ${mentioned.length} explicitly listed.`,
544
+ 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.`,
522
545
  evidence: { mentioned, totalBots: AI_BOTS.length }
523
546
  };
524
547
  }
@@ -535,15 +558,17 @@ var llmsTxtPresentRule = defineRule({
535
558
  effort: "medium",
536
559
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerllms-txt-present",
537
560
  title: "llms.txt is present",
561
+ title_ko: "llms.txt \uD30C\uC77C \uC874\uC7AC \uC5EC\uBD80",
538
562
  description: "An /llms.txt file at the site root gives AI assistants a curated map of the most citation-worthy pages.",
539
563
  run(ctx) {
540
564
  if (ctx.llmsTxt) {
541
- return { status: "pass", score: 1, rationale: "llms.txt found at site root." };
565
+ 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." };
542
566
  }
543
567
  return {
544
568
  status: "warn",
545
569
  score: 0,
546
570
  rationale: "No /llms.txt found. Add one to curate the pages AI assistants should read.",
571
+ 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.",
547
572
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
548
573
  };
549
574
  }
@@ -560,10 +585,11 @@ var llmsTxtWellformedRule = defineRule({
560
585
  effort: "low",
561
586
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerllms-txt-wellformed",
562
587
  title: "llms.txt follows the spec",
588
+ title_ko: "llms.txt \uC2A4\uD399 \uC900\uC218 \uC5EC\uBD80",
563
589
  description: "Must start with an H1 project title, then a brief summary, then at least one H2 section containing link items.",
564
590
  run(ctx) {
565
591
  if (!ctx.llmsTxt) {
566
- return { status: "skip", score: 0, rationale: "No llms.txt to validate." };
592
+ return { status: "skip", score: 0, rationale: "No llms.txt to validate.", rationale_ko: "\uAC80\uC99D\uD560 llms.txt\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
567
593
  }
568
594
  const check = isLlmsTxtWellFormed(ctx.llmsTxt);
569
595
  if (check.ok) {
@@ -571,13 +597,15 @@ var llmsTxtWellformedRule = defineRule({
571
597
  return {
572
598
  status: "pass",
573
599
  score: 1,
574
- rationale: `Well-formed with ${ctx.llmsTxt.sections.length} section(s) and ${totalLinks} link(s).`
600
+ rationale: `Well-formed with ${ctx.llmsTxt.sections.length} section(s) and ${totalLinks} link(s).`,
601
+ rationale_ko: `\uC2A4\uD399\uC5D0 \uB9DE\uAC8C \uC791\uC131\uB428 (\uC139\uC158 ${ctx.llmsTxt.sections.length}\uAC1C, \uB9C1\uD06C ${totalLinks}\uAC1C).`
575
602
  };
576
603
  }
577
604
  return {
578
605
  status: "warn",
579
606
  score: 0.3,
580
607
  rationale: `llms.txt does not fully match the spec: ${check.reason}.`,
608
+ rationale_ko: `llms.txt\uAC00 \uC2A4\uD399\uC744 \uC644\uC804\uD788 \uB530\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4: ${check.reason}.`,
581
609
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
582
610
  };
583
611
  }
@@ -594,13 +622,15 @@ var llmsFullTxtRule = defineRule({
594
622
  effort: "medium",
595
623
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerllms-full-txt",
596
624
  title: "llms-full.txt provides full-content mirror",
625
+ title_ko: "llms-full.txt \uC804\uCCB4 \uCF58\uD150\uCE20 \uBBF8\uB7EC \uC81C\uACF5 \uC5EC\uBD80",
597
626
  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.",
598
627
  run(ctx) {
599
628
  if (ctx.llmsFullTxt && ctx.llmsFullTxt.length > 200) {
600
629
  return {
601
630
  status: "pass",
602
631
  score: 1,
603
- rationale: `/llms-full.txt found (${ctx.llmsFullTxt.length.toLocaleString()} chars).`
632
+ rationale: `/llms-full.txt found (${ctx.llmsFullTxt.length.toLocaleString()} chars).`,
633
+ rationale_ko: `/llms-full.txt\uAC00 \uC874\uC7AC\uD569\uB2C8\uB2E4 (${ctx.llmsFullTxt.length.toLocaleString()}\uC790).`
604
634
  };
605
635
  }
606
636
  if (ctx.llmsFullTxt) {
@@ -608,6 +638,7 @@ var llmsFullTxtRule = defineRule({
608
638
  status: "warn",
609
639
  score: 0.5,
610
640
  rationale: `/llms-full.txt found but very short (${ctx.llmsFullTxt.length} chars). Consider expanding with page bodies.`,
641
+ 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.`,
611
642
  fixHint: "Mirror full article bodies into /llms-full.txt so AI assistants can quote without re-crawling."
612
643
  };
613
644
  }
@@ -615,6 +646,7 @@ var llmsFullTxtRule = defineRule({
615
646
  status: "warn",
616
647
  score: 0,
617
648
  rationale: "No /llms-full.txt found. Adding one lets AI assistants ingest the full corpus in a single request.",
649
+ 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.",
618
650
  fixHint: "Publish /llms-full.txt alongside /llms.txt with the full body text of your top pages.",
619
651
  estimatedImpact: 1
620
652
  };
@@ -632,19 +664,22 @@ var sitemapPresentRule = defineRule({
632
664
  effort: "low",
633
665
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlersitemap-present",
634
666
  title: "sitemap.xml is present",
667
+ title_ko: "sitemap.xml \uC874\uC7AC \uC5EC\uBD80",
635
668
  description: "A sitemap helps AI crawlers discover and prioritise pages; many crawlers short-circuit discovery without one.",
636
669
  run(ctx) {
637
670
  if (ctx.sitemap && ctx.sitemap.urls.length > 0) {
638
671
  return {
639
672
  status: "pass",
640
673
  score: 1,
641
- rationale: `Sitemap found with ${ctx.sitemap.urls.length} URL(s).`
674
+ rationale: `Sitemap found with ${ctx.sitemap.urls.length} URL(s).`,
675
+ rationale_ko: `\uC0AC\uC774\uD2B8\uB9F5\uC5D0 URL\uC774 ${ctx.sitemap.urls.length}\uAC1C \uC788\uC2B5\uB2C8\uB2E4.`
642
676
  };
643
677
  }
644
678
  return {
645
679
  status: "warn",
646
680
  score: 0.2,
647
681
  rationale: "No sitemap.xml found (checked /sitemap.xml and Sitemap: directive in robots.txt).",
682
+ 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).",
648
683
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
649
684
  };
650
685
  }
@@ -672,15 +707,17 @@ var jsonLdPresentRule = defineRule({
672
707
  effort: "medium",
673
708
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdjsonld-present",
674
709
  title: "JSON-LD structured data is present",
710
+ title_ko: "JSON-LD \uAD6C\uC870\uD654 \uB370\uC774\uD130 \uC874\uC7AC \uC5EC\uBD80",
675
711
  description: 'At least one <script type="application/ld+json"> block is the primary way AI engines map your page to an entity.',
676
712
  run(ctx) {
677
713
  if (ctx.jsonLd.length > 0) {
678
- return { status: "pass", score: 1, rationale: `Found ${ctx.jsonLd.length} JSON-LD block(s).` };
714
+ 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.` };
679
715
  }
680
716
  return {
681
717
  status: "fail",
682
718
  score: 0,
683
719
  rationale: "No JSON-LD blocks found. Add schema.org structured data.",
720
+ 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.",
684
721
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
685
722
  };
686
723
  }
@@ -756,20 +793,22 @@ var jsonLdValidJsonRule = defineRule({
756
793
  effort: "low",
757
794
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdjsonld-valid-json",
758
795
  title: "JSON-LD blocks parse as valid JSON",
796
+ title_ko: "JSON-LD \uBE14\uB85D\uC758 JSON \uC720\uD6A8\uC131",
759
797
  description: "Malformed JSON in an ld+json block is silently ignored by most consumers \u2014 a costly silent failure.",
760
798
  run(ctx) {
761
799
  if (ctx.jsonLd.length === 0) {
762
- return { status: "skip", score: 0, rationale: "No JSON-LD to validate." };
800
+ return { status: "skip", score: 0, rationale: "No JSON-LD to validate.", rationale_ko: "\uAC80\uC99D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
763
801
  }
764
802
  if (hasParseError(ctx.jsonLd)) {
765
803
  return {
766
804
  status: "fail",
767
805
  score: 0,
768
806
  rationale: "One or more JSON-LD blocks failed to parse.",
807
+ rationale_ko: "JSON-LD \uBE14\uB85D \uD558\uB098 \uC774\uC0C1\uC744 \uD30C\uC2F1\uD558\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.",
769
808
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
770
809
  };
771
810
  }
772
- return { status: "pass", score: 1, rationale: "All JSON-LD blocks parse cleanly." };
811
+ 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." };
773
812
  }
774
813
  });
775
814
 
@@ -784,10 +823,11 @@ var schemaTypeRecognizedRule = defineRule({
784
823
  effort: "low",
785
824
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdschema-type-recognized",
786
825
  title: "Schema.org @type is a recognised kind",
826
+ title_ko: "Schema.org @type \uC778\uC2DD \uAC00\uB2A5 \uC5EC\uBD80",
787
827
  description: "AI engines match pages against well-known types (Article, Product, FAQPage...). Obscure types weaken the signal.",
788
828
  run(ctx) {
789
829
  if (ctx.jsonLd.length === 0) {
790
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
830
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
791
831
  }
792
832
  const nodes = flattenJsonLd(ctx.jsonLd);
793
833
  const recognized = /* @__PURE__ */ new Set();
@@ -803,6 +843,7 @@ var schemaTypeRecognizedRule = defineRule({
803
843
  status: "pass",
804
844
  score: 1,
805
845
  rationale: `Recognised: ${[...recognized].join(", ")}.`,
846
+ rationale_ko: `\uC778\uC2DD\uB41C \uD0C0\uC785: ${[...recognized].join(", ")}.`,
806
847
  evidence: { recognized: [...recognized], all: [...seenTypes] }
807
848
  };
808
849
  }
@@ -810,6 +851,7 @@ var schemaTypeRecognizedRule = defineRule({
810
851
  status: "warn",
811
852
  score: 0.3,
812
853
  rationale: `No recognised schema.org types. Saw: ${[...seenTypes].join(", ") || "(none)"}.`,
854
+ 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)"}.`,
813
855
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
814
856
  };
815
857
  }
@@ -826,10 +868,11 @@ var requiredFieldsRule = defineRule({
826
868
  effort: "medium",
827
869
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdrequired-fields",
828
870
  title: "Required fields for recognised types are set",
871
+ title_ko: "\uC778\uC2DD\uB41C \uD0C0\uC785\uC758 \uD544\uC218 \uD544\uB4DC \uCDA9\uC871 \uC5EC\uBD80",
829
872
  description: "Article needs headline/author/datePublished, FAQPage needs mainEntity, Product needs offers, etc.",
830
873
  run(ctx) {
831
874
  if (ctx.jsonLd.length === 0) {
832
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
875
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
833
876
  }
834
877
  const nodes = flattenJsonLd(ctx.jsonLd);
835
878
  const missing = [];
@@ -848,14 +891,16 @@ var requiredFieldsRule = defineRule({
848
891
  return {
849
892
  status: "skip",
850
893
  score: 0,
851
- rationale: "No types with known required fields were found."
894
+ rationale: "No types with known required fields were found.",
895
+ rationale_ko: "\uD544\uC218 \uD544\uB4DC\uAC00 \uC815\uC758\uB41C \uD0C0\uC785\uC774 \uC5C6\uC2B5\uB2C8\uB2E4."
852
896
  };
853
897
  }
854
898
  if (missing.length === 0) {
855
899
  return {
856
900
  status: "pass",
857
901
  score: 1,
858
- rationale: `Required fields set on ${checked.length} node(s).`
902
+ rationale: `Required fields set on ${checked.length} node(s).`,
903
+ rationale_ko: `${checked.length}\uAC1C \uB178\uB4DC\uC758 \uD544\uC218 \uD544\uB4DC\uAC00 \uBAA8\uB450 \uCDA9\uC871\uB429\uB2C8\uB2E4.`
859
904
  };
860
905
  }
861
906
  const msg = missing.map((m) => `${m.type}.${m.field}`).join(", ");
@@ -863,6 +908,7 @@ var requiredFieldsRule = defineRule({
863
908
  status: "fail",
864
909
  score: Math.max(0, 1 - missing.length / (checked.length * 2)),
865
910
  rationale: `Missing required fields: ${msg}.`,
911
+ rationale_ko: `\uB204\uB77D\uB41C \uD544\uC218 \uD544\uB4DC: ${msg}.`,
866
912
  evidence: missing,
867
913
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
868
914
  };
@@ -880,10 +926,11 @@ var microdataFallbackRule = defineRule({
880
926
  effort: "medium",
881
927
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdmicrodata-fallback",
882
928
  title: "Microdata or RDFa fallback when JSON-LD is missing",
929
+ title_ko: "JSON-LD \uC5C6\uC744 \uB54C Microdata/RDFa \uB300\uCCB4 \uC5EC\uBD80",
883
930
  description: "If JSON-LD is absent, inline microdata (itemscope/itemtype) or RDFa still gives some structured signal.",
884
931
  run(ctx) {
885
932
  if (ctx.jsonLd.length > 0) {
886
- return { status: "skip", score: 0, rationale: "JSON-LD is present; fallback not needed." };
933
+ 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." };
887
934
  }
888
935
  const microdata = ctx.$("[itemscope][itemtype]").length;
889
936
  const rdfa = ctx.$("[typeof][vocab], [typeof][property]").length;
@@ -891,13 +938,15 @@ var microdataFallbackRule = defineRule({
891
938
  return {
892
939
  status: "pass",
893
940
  score: 1,
894
- rationale: `Found ${microdata} microdata and ${rdfa} RDFa nodes.`
941
+ rationale: `Found ${microdata} microdata and ${rdfa} RDFa nodes.`,
942
+ rationale_ko: `Microdata ${microdata}\uAC1C, RDFa ${rdfa}\uAC1C \uBC1C\uACAC\uB429\uB2C8\uB2E4.`
895
943
  };
896
944
  }
897
945
  return {
898
946
  status: "fail",
899
947
  score: 0,
900
948
  rationale: "No structured data at all (no JSON-LD, no microdata, no RDFa).",
949
+ rationale_ko: "\uAD6C\uC870\uD654 \uB370\uC774\uD130\uAC00 \uC804\uD600 \uC5C6\uC2B5\uB2C8\uB2E4 (JSON-LD, Microdata, RDFa \uBAA8\uB450 \uC5C6\uC74C).",
901
950
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
902
951
  };
903
952
  }
@@ -915,10 +964,11 @@ var noDuplicateTypesRule = defineRule({
915
964
  effort: "low",
916
965
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdno-duplicate-types",
917
966
  title: "No conflicting duplicate @types",
967
+ title_ko: "@type \uC911\uBCF5 \uCDA9\uB3CC \uC5C6\uC74C",
918
968
  description: "Multiple competing entities of the same primary type (e.g. two Articles) confuse the engine about which one represents the page.",
919
969
  run(ctx) {
920
970
  if (ctx.jsonLd.length === 0) {
921
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
971
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
922
972
  }
923
973
  const counts = /* @__PURE__ */ new Map();
924
974
  for (const node of flattenJsonLd(ctx.jsonLd)) {
@@ -928,12 +978,13 @@ var noDuplicateTypesRule = defineRule({
928
978
  }
929
979
  const dupes = [...counts.entries()].filter(([, n]) => n > 1);
930
980
  if (dupes.length === 0) {
931
- return { status: "pass", score: 1, rationale: "No duplicate primary types." };
981
+ return { status: "pass", score: 1, rationale: "No duplicate primary types.", rationale_ko: "\uC911\uBCF5\uB41C \uAE30\uBCF8 \uD0C0\uC785\uC774 \uC5C6\uC2B5\uB2C8\uB2E4." };
932
982
  }
933
983
  return {
934
984
  status: "warn",
935
985
  score: 0.4,
936
986
  rationale: `Duplicate primary types: ${dupes.map(([t, n]) => `${t}\xD7${n}`).join(", ")}.`,
987
+ rationale_ko: `\uC911\uBCF5\uB41C \uAE30\uBCF8 \uD0C0\uC785: ${dupes.map(([t, n]) => `${t}\xD7${n}`).join(", ")}.`,
937
988
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
938
989
  };
939
990
  }
@@ -981,10 +1032,11 @@ var sameAsEntityRule = defineRule({
981
1032
  effort: "medium",
982
1033
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdsameas-entity",
983
1034
  title: "Entity nodes link the knowledge graph via sameAs",
1035
+ title_ko: "sameAs\uB85C \uC9C0\uC2DD \uADF8\uB798\uD504 \uC5F0\uACB0 \uC5EC\uBD80",
984
1036
  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).",
985
1037
  run(ctx) {
986
1038
  if (ctx.jsonLd.length === 0) {
987
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
1039
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
988
1040
  }
989
1041
  const nodes = flattenJsonLd(ctx.jsonLd);
990
1042
  const entities = nodes.filter((n) => getTypes(n).some((t) => ENTITY_TYPES.includes(t)));
@@ -992,7 +1044,8 @@ var sameAsEntityRule = defineRule({
992
1044
  return {
993
1045
  status: "skip",
994
1046
  score: 0,
995
- rationale: "No Organization/Person/LocalBusiness/Brand entity to link."
1047
+ rationale: "No Organization/Person/LocalBusiness/Brand entity to link.",
1048
+ rationale_ko: "\uC5F0\uACB0\uD560 Organization/Person/LocalBusiness/Brand \uC5D4\uD2F0\uD2F0\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4."
996
1049
  };
997
1050
  }
998
1051
  let bestScore = 0;
@@ -1016,6 +1069,7 @@ var sameAsEntityRule = defineRule({
1016
1069
  status: "pass",
1017
1070
  score: 1,
1018
1071
  rationale: `Entity links ${bestEvidence.trusted} trusted knowledge-graph hosts via sameAs.`,
1072
+ 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.`,
1019
1073
  evidence: bestEvidence
1020
1074
  };
1021
1075
  }
@@ -1024,6 +1078,7 @@ var sameAsEntityRule = defineRule({
1024
1078
  status: "pass",
1025
1079
  score: bestScore,
1026
1080
  rationale: `Entity has 1 trusted sameAs link. Add Wikipedia/Wikidata for stronger E-E-A-T.`,
1081
+ 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.",
1027
1082
  evidence: bestEvidence,
1028
1083
  estimatedImpact: 1
1029
1084
  };
@@ -1033,6 +1088,7 @@ var sameAsEntityRule = defineRule({
1033
1088
  status: "warn",
1034
1089
  score: bestScore,
1035
1090
  rationale: "Entity declares sameAs but no trusted knowledge-graph hosts (Wikipedia/Wikidata/LinkedIn).",
1091
+ 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.",
1036
1092
  evidence: bestEvidence,
1037
1093
  fixHint: "Add Wikipedia/Wikidata/LinkedIn URLs to your Organization sameAs[].",
1038
1094
  estimatedImpact: 2
@@ -1042,6 +1098,7 @@ var sameAsEntityRule = defineRule({
1042
1098
  status: "warn",
1043
1099
  score: 0,
1044
1100
  rationale: `${entities.length} entity node(s) found but none declare sameAs links.`,
1101
+ rationale_ko: `\uC5D4\uD2F0\uD2F0 \uB178\uB4DC\uAC00 ${entities.length}\uAC1C \uC788\uC9C0\uB9CC sameAs \uB9C1\uD06C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.`,
1045
1102
  fixHint: 'Add sameAs:["https://en.wikipedia.org/wiki/...", "https://www.linkedin.com/company/..."] to your Organization JSON-LD.',
1046
1103
  estimatedImpact: 3
1047
1104
  };
@@ -1073,15 +1130,16 @@ var breadcrumbValidRule = defineRule({
1073
1130
  effort: "medium",
1074
1131
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdbreadcrumb-valid",
1075
1132
  title: "BreadcrumbList items declare position, name, and item",
1133
+ title_ko: "BreadcrumbList \uD56D\uBAA9\uC758 \uD544\uC218 \uD544\uB4DC \uCDA9\uC871 \uC5EC\uBD80",
1076
1134
  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.",
1077
1135
  run(ctx) {
1078
1136
  if (ctx.jsonLd.length === 0) {
1079
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
1137
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
1080
1138
  }
1081
1139
  const nodes = flattenJsonLd(ctx.jsonLd);
1082
1140
  const breadcrumbs = nodes.filter((n) => getTypes(n).includes("BreadcrumbList"));
1083
1141
  if (breadcrumbs.length === 0) {
1084
- return { status: "skip", score: 0, rationale: "No BreadcrumbList present." };
1142
+ return { status: "skip", score: 0, rationale: "No BreadcrumbList present.", rationale_ko: "BreadcrumbList\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
1085
1143
  }
1086
1144
  const allIssues = [];
1087
1145
  let totalItems = 0;
@@ -1094,7 +1152,8 @@ var breadcrumbValidRule = defineRule({
1094
1152
  return {
1095
1153
  status: "pass",
1096
1154
  score: 1,
1097
- rationale: `BreadcrumbList(s) valid (${totalItems} items).`
1155
+ rationale: `BreadcrumbList(s) valid (${totalItems} items).`,
1156
+ rationale_ko: `BreadcrumbList\uAC00 \uC720\uD6A8\uD569\uB2C8\uB2E4 (\uD56D\uBAA9 ${totalItems}\uAC1C).`
1098
1157
  };
1099
1158
  }
1100
1159
  const fatalCount = allIssues.length;
@@ -1104,6 +1163,7 @@ var breadcrumbValidRule = defineRule({
1104
1163
  status: score < 0.5 ? "fail" : "warn",
1105
1164
  score,
1106
1165
  rationale: `${fatalCount} breadcrumb item(s) missing required fields.`,
1166
+ rationale_ko: `breadcrumb \uD56D\uBAA9 ${fatalCount}\uAC1C\uC5D0 \uD544\uC218 \uD544\uB4DC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.`,
1107
1167
  evidence: allIssues.slice(0, 5),
1108
1168
  fixHint: 'Each itemListElement needs { "@type": "ListItem", position: N, name, item }.',
1109
1169
  estimatedImpact: Math.round(2 * (1 - score))
@@ -1134,6 +1194,7 @@ var titleRule = defineRule({
1134
1194
  effort: "low",
1135
1195
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cittitle",
1136
1196
  title: "<title> is set with a reasonable length",
1197
+ title_ko: "<title> \uD0DC\uADF8 \uC801\uC815 \uAE38\uC774 \uC124\uC815 \uC5EC\uBD80",
1137
1198
  description: "The document title is the single most-cited piece of text and should be 10\u201370 characters.",
1138
1199
  run(ctx) {
1139
1200
  const title = ctx.$("head > title").first().text().trim();
@@ -1142,6 +1203,7 @@ var titleRule = defineRule({
1142
1203
  status: "fail",
1143
1204
  score: 0,
1144
1205
  rationale: "Page has no <title>.",
1206
+ rationale_ko: "\uD398\uC774\uC9C0\uC5D0 <title>\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.",
1145
1207
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1146
1208
  };
1147
1209
  }
@@ -1149,17 +1211,19 @@ var titleRule = defineRule({
1149
1211
  return {
1150
1212
  status: "warn",
1151
1213
  score: 0.4,
1152
- rationale: `Title is only ${title.length} chars; consider a more descriptive one.`
1214
+ rationale: `Title is only ${title.length} chars; consider a more descriptive one.`,
1215
+ 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.`
1153
1216
  };
1154
1217
  }
1155
1218
  if (title.length > 70) {
1156
1219
  return {
1157
1220
  status: "warn",
1158
1221
  score: 0.6,
1159
- rationale: `Title is ${title.length} chars; search UIs commonly truncate after ~70.`
1222
+ rationale: `Title is ${title.length} chars; search UIs commonly truncate after ~70.`,
1223
+ 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.`
1160
1224
  };
1161
1225
  }
1162
- return { status: "pass", score: 1, rationale: `Title length ${title.length} is within range.` };
1226
+ 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.` };
1163
1227
  }
1164
1228
  });
1165
1229
 
@@ -1174,6 +1238,7 @@ var metaDescriptionRule = defineRule({
1174
1238
  effort: "low",
1175
1239
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citmeta-description",
1176
1240
  title: "meta description is set (50\u2013160 chars)",
1241
+ title_ko: "meta description \uC124\uC815 \uC5EC\uBD80 (50\u2013160\uC790)",
1177
1242
  description: "AI snippets often quote the meta description verbatim; aim for 50\u2013160 chars.",
1178
1243
  run(ctx) {
1179
1244
  const desc = ctx.$('head meta[name="description"]').attr("content")?.trim() ?? "";
@@ -1182,16 +1247,17 @@ var metaDescriptionRule = defineRule({
1182
1247
  status: "warn",
1183
1248
  score: 0,
1184
1249
  rationale: "No meta description set.",
1250
+ rationale_ko: "meta description\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.",
1185
1251
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1186
1252
  };
1187
1253
  }
1188
1254
  if (desc.length < 50) {
1189
- return { status: "warn", score: 0.5, rationale: `Only ${desc.length} chars; aim for 50+.` };
1255
+ 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.` };
1190
1256
  }
1191
1257
  if (desc.length > 160) {
1192
- return { status: "warn", score: 0.7, rationale: `${desc.length} chars; may be truncated after 160.` };
1258
+ 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.` };
1193
1259
  }
1194
- return { status: "pass", score: 1, rationale: `Description length ${desc.length} is within range.` };
1260
+ 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.` };
1195
1261
  }
1196
1262
  });
1197
1263
 
@@ -1206,6 +1272,7 @@ var canonicalRule = defineRule({
1206
1272
  effort: "low",
1207
1273
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citcanonical",
1208
1274
  title: "Canonical URL is declared",
1275
+ title_ko: "Canonical URL \uC120\uC5B8 \uC5EC\uBD80",
1209
1276
  description: 'rel="canonical" tells crawlers which URL is the source of truth, preventing duplicate-citation confusion.',
1210
1277
  run(ctx) {
1211
1278
  const href = ctx.$('head link[rel="canonical"]').attr("href")?.trim();
@@ -1214,14 +1281,15 @@ var canonicalRule = defineRule({
1214
1281
  status: "warn",
1215
1282
  score: 0,
1216
1283
  rationale: 'No <link rel="canonical"> found.',
1284
+ rationale_ko: '<link rel="canonical">\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.',
1217
1285
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1218
1286
  };
1219
1287
  }
1220
1288
  try {
1221
1289
  const abs = new URL(href, ctx.finalUrl).toString();
1222
- return { status: "pass", score: 1, rationale: `Canonical URL: ${abs}.` };
1290
+ return { status: "pass", score: 1, rationale: `Canonical URL: ${abs}.`, rationale_ko: `Canonical URL: ${abs}.` };
1223
1291
  } catch {
1224
- return { status: "fail", score: 0, rationale: `Canonical href is not a valid URL: ${href}` };
1292
+ 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}` };
1225
1293
  }
1226
1294
  }
1227
1295
  });
@@ -1238,6 +1306,7 @@ var ogTagsRule = defineRule({
1238
1306
  effort: "low",
1239
1307
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citog-tags",
1240
1308
  title: "Open Graph tags are set",
1309
+ title_ko: "Open Graph \uD0DC\uADF8 \uC124\uC815 \uC5EC\uBD80",
1241
1310
  description: "og:title/type/url/image power rich previews on AI chat, social, and messaging.",
1242
1311
  run(ctx) {
1243
1312
  const missing = [];
@@ -1246,13 +1315,14 @@ var ogTagsRule = defineRule({
1246
1315
  if (!val) missing.push(prop);
1247
1316
  }
1248
1317
  if (missing.length === 0) {
1249
- return { status: "pass", score: 1, rationale: "All required OG tags present." };
1318
+ 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." };
1250
1319
  }
1251
1320
  const ratio = 1 - missing.length / REQUIRED.length;
1252
1321
  return {
1253
1322
  status: missing.length === REQUIRED.length ? "fail" : "warn",
1254
1323
  score: ratio,
1255
1324
  rationale: `Missing: ${missing.join(", ")}.`,
1325
+ rationale_ko: `\uB204\uB77D\uB41C \uD0DC\uADF8: ${missing.join(", ")}.`,
1256
1326
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1257
1327
  };
1258
1328
  }
@@ -1269,20 +1339,22 @@ var twitterCardRule = defineRule({
1269
1339
  effort: "low",
1270
1340
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cittwitter-card",
1271
1341
  title: "Twitter Card metadata is set",
1342
+ title_ko: "Twitter Card \uBA54\uD0C0\uB370\uC774\uD130 \uC124\uC815 \uC5EC\uBD80",
1272
1343
  description: "twitter:card + twitter:title give better previews on X/Twitter and some AI surfaces that reuse the tags.",
1273
1344
  run(ctx) {
1274
1345
  const card = ctx.$('head meta[name="twitter:card"]').attr("content")?.trim();
1275
1346
  const title = ctx.$('head meta[name="twitter:title"]').attr("content")?.trim();
1276
1347
  if (card && title) {
1277
- return { status: "pass", score: 1, rationale: `Card type: ${card}.` };
1348
+ return { status: "pass", score: 1, rationale: `Card type: ${card}.`, rationale_ko: `\uCE74\uB4DC \uC720\uD615: ${card}.` };
1278
1349
  }
1279
1350
  if (card || title) {
1280
- return { status: "warn", score: 0.5, rationale: "Partial twitter:* metadata; add the missing tag." };
1351
+ 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." };
1281
1352
  }
1282
1353
  return {
1283
1354
  status: "warn",
1284
1355
  score: 0,
1285
1356
  rationale: "No twitter:card metadata.",
1357
+ rationale_ko: "twitter:card \uBA54\uD0C0\uB370\uC774\uD130\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.",
1286
1358
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1287
1359
  };
1288
1360
  }
@@ -1299,6 +1371,7 @@ var langAttrRule = defineRule({
1299
1371
  effort: "low",
1300
1372
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citlang-attr",
1301
1373
  title: "<html lang> is set",
1374
+ title_ko: "<html lang> \uC18D\uC131 \uC124\uC815 \uC5EC\uBD80",
1302
1375
  description: "A lang attribute helps AI engines route the page to the right-language search surface (and helps screen readers).",
1303
1376
  run(ctx) {
1304
1377
  const lang = ctx.$("html").attr("lang")?.trim();
@@ -1307,10 +1380,11 @@ var langAttrRule = defineRule({
1307
1380
  status: "warn",
1308
1381
  score: 0,
1309
1382
  rationale: "No lang attribute on <html>.",
1383
+ rationale_ko: "<html>\uC5D0 lang \uC18D\uC131\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.",
1310
1384
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1311
1385
  };
1312
1386
  }
1313
- return { status: "pass", score: 1, rationale: `lang="${lang}".` };
1387
+ return { status: "pass", score: 1, rationale: `lang="${lang}".`, rationale_ko: `lang="${lang}".` };
1314
1388
  }
1315
1389
  });
1316
1390
 
@@ -1325,25 +1399,27 @@ var authorVisibleRule = defineRule({
1325
1399
  effort: "medium",
1326
1400
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citauthor-visible",
1327
1401
  title: "Author is declared",
1402
+ title_ko: "\uC791\uC131\uC790 \uC815\uBCF4 \uC120\uC5B8 \uC5EC\uBD80",
1328
1403
  description: "AI engines prefer citing content with an identifiable author; expose one via JSON-LD, meta[name=author], rel=author, or a .author class.",
1329
1404
  run(ctx) {
1330
1405
  for (const node of flattenJsonLd(ctx.jsonLd)) {
1331
1406
  if (hasField(node, "author")) {
1332
- return { status: "pass", score: 1, rationale: "Author found in JSON-LD." };
1407
+ 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." };
1333
1408
  }
1334
1409
  }
1335
1410
  const metaAuthor = ctx.$('head meta[name="author"]').attr("content")?.trim();
1336
- if (metaAuthor) return { status: "pass", score: 1, rationale: `meta[name=author] = "${metaAuthor}".` };
1411
+ if (metaAuthor) return { status: "pass", score: 1, rationale: `meta[name=author] = "${metaAuthor}".`, rationale_ko: `meta[name=author] = "${metaAuthor}".` };
1337
1412
  if (ctx.$('[rel="author"]').length > 0) {
1338
- return { status: "pass", score: 1, rationale: 'rel="author" link found.' };
1413
+ return { status: "pass", score: 1, rationale: 'rel="author" link found.', rationale_ko: 'rel="author" \uB9C1\uD06C\uB97C \uCC3E\uC558\uC2B5\uB2C8\uB2E4.' };
1339
1414
  }
1340
1415
  if (ctx.$('.author, [class*="author"], [itemprop="author"]').length > 0) {
1341
- return { status: "pass", score: 0.8, rationale: "Author-ish DOM selector found (weaker signal)." };
1416
+ 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)." };
1342
1417
  }
1343
1418
  return {
1344
1419
  status: "warn",
1345
1420
  score: 0,
1346
1421
  rationale: "No author signal found (JSON-LD, meta, rel, or .author).",
1422
+ rationale_ko: "\uC791\uC131\uC790 \uC815\uBCF4\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4 (JSON-LD, meta, rel, .author \uBAA8\uB450 \uC5C6\uC74C).",
1347
1423
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1348
1424
  };
1349
1425
  }
@@ -1360,25 +1436,27 @@ var datesRule = defineRule({
1360
1436
  effort: "low",
1361
1437
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citdates",
1362
1438
  title: "Publish / modified date is present",
1439
+ title_ko: "\uBC1C\uD589\uC77C / \uC218\uC815\uC77C \uC874\uC7AC \uC5EC\uBD80",
1363
1440
  description: "AI engines rank recent pages higher; expose datePublished via JSON-LD, <time datetime>, or article:published_time meta.",
1364
1441
  run(ctx) {
1365
1442
  for (const node of flattenJsonLd(ctx.jsonLd)) {
1366
1443
  if (hasField(node, "datePublished")) {
1367
- return { status: "pass", score: 1, rationale: "datePublished found in JSON-LD." };
1444
+ return { status: "pass", score: 1, rationale: "datePublished found in JSON-LD.", rationale_ko: "JSON-LD\uC5D0\uC11C datePublished\uB97C \uCC3E\uC558\uC2B5\uB2C8\uB2E4." };
1368
1445
  }
1369
1446
  }
1370
1447
  const articleTime = ctx.$('head meta[property="article:published_time"]').attr("content")?.trim();
1371
1448
  if (articleTime) {
1372
- return { status: "pass", score: 1, rationale: `article:published_time = ${articleTime}.` };
1449
+ return { status: "pass", score: 1, rationale: `article:published_time = ${articleTime}.`, rationale_ko: `article:published_time = ${articleTime}.` };
1373
1450
  }
1374
1451
  const timeEl = ctx.$("time[datetime]").first().attr("datetime")?.trim();
1375
1452
  if (timeEl) {
1376
- return { status: "pass", score: 0.8, rationale: `<time datetime="${timeEl}"> found.` };
1453
+ return { status: "pass", score: 0.8, rationale: `<time datetime="${timeEl}"> found.`, rationale_ko: `<time datetime="${timeEl}">\uB97C \uCC3E\uC558\uC2B5\uB2C8\uB2E4.` };
1377
1454
  }
1378
1455
  return {
1379
1456
  status: "warn",
1380
1457
  score: 0,
1381
1458
  rationale: "No publish date found (JSON-LD, meta article:published_time, or <time datetime>).",
1459
+ rationale_ko: "\uBC1C\uD589\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4 (JSON-LD, meta article:published_time, <time datetime> \uBAA8\uB450 \uC5C6\uC74C).",
1382
1460
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1383
1461
  };
1384
1462
  }
@@ -1406,6 +1484,7 @@ var contentFreshnessRule = defineRule({
1406
1484
  effort: "low",
1407
1485
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citcontent-freshness",
1408
1486
  title: "Article content is fresh (dateModified within 1 year)",
1487
+ title_ko: "\uCF58\uD150\uCE20 \uCD5C\uC2E0\uC131 (dateModified 1\uB144 \uC774\uB0B4)",
1409
1488
  description: "AI engines down-rank stale content. Surface a recent dateModified (\u2264365 days) on Article-like pages so retrieval rankings stay strong.",
1410
1489
  run(ctx) {
1411
1490
  const nodes = flattenJsonLd(ctx.jsonLd);
@@ -1414,7 +1493,8 @@ var contentFreshnessRule = defineRule({
1414
1493
  return {
1415
1494
  status: "skip",
1416
1495
  score: 0,
1417
- rationale: "No Article/BlogPosting/NewsArticle JSON-LD; freshness signal not applicable."
1496
+ rationale: "No Article/BlogPosting/NewsArticle JSON-LD; freshness signal not applicable.",
1497
+ rationale_ko: "Article/BlogPosting/NewsArticle JSON-LD\uAC00 \uC5C6\uC5B4 \uCD5C\uC2E0\uC131 \uC2E0\uD638\uB97C \uD655\uC778\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4."
1418
1498
  };
1419
1499
  }
1420
1500
  let bestMs = null;
@@ -1436,6 +1516,7 @@ var contentFreshnessRule = defineRule({
1436
1516
  status: "warn",
1437
1517
  score: 0,
1438
1518
  rationale: "Article has no parseable dateModified or datePublished.",
1519
+ rationale_ko: "Article JSON-LD\uC5D0 \uD30C\uC2F1 \uAC00\uB2A5\uD55C dateModified \uB610\uB294 datePublished\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.",
1439
1520
  fixHint: "Add ISO-8601 dateModified and datePublished to your Article JSON-LD.",
1440
1521
  estimatedImpact: 3
1441
1522
  };
@@ -1446,6 +1527,7 @@ var contentFreshnessRule = defineRule({
1446
1527
  status: "pass",
1447
1528
  score: 1,
1448
1529
  rationale: `${usedField} within the last year (~${ageDays} day${ageDays === 1 ? "" : "s"} ago).`,
1530
+ rationale_ko: `${usedField}\uC774 1\uB144 \uC774\uB0B4\uC785\uB2C8\uB2E4 (\uC57D ${ageDays}\uC77C \uC804).`,
1449
1531
  evidence: { ageDays, field: usedField }
1450
1532
  };
1451
1533
  }
@@ -1454,6 +1536,7 @@ var contentFreshnessRule = defineRule({
1454
1536
  status: "warn",
1455
1537
  score: 0.6,
1456
1538
  rationale: `${usedField} is ${ageDays} days old. Refresh within a year for best AI ranking.`,
1539
+ 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.`,
1457
1540
  evidence: { ageDays, field: usedField },
1458
1541
  estimatedImpact: 2
1459
1542
  };
@@ -1462,6 +1545,7 @@ var contentFreshnessRule = defineRule({
1462
1545
  status: "warn",
1463
1546
  score: 0.2,
1464
1547
  rationale: `${usedField} is ${ageDays} days old (>2 years). AI engines treat this as stale.`,
1548
+ 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.`,
1465
1549
  evidence: { ageDays, field: usedField },
1466
1550
  fixHint: "Update content and bump dateModified to today's date.",
1467
1551
  estimatedImpact: 3
@@ -1493,22 +1577,25 @@ var singleH1Rule = defineRule({
1493
1577
  effort: "low",
1494
1578
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntsingle-h1",
1495
1579
  title: "Exactly one <h1>",
1580
+ title_ko: "<h1> \uD0DC\uADF8 1\uAC1C \uC5EC\uBD80",
1496
1581
  description: "A single H1 tells AI engines the primary topic of the page without ambiguity.",
1497
1582
  run(ctx) {
1498
1583
  const n = ctx.$("h1").length;
1499
- if (n === 1) return { status: "pass", score: 1, rationale: "Exactly one <h1>." };
1584
+ if (n === 1) return { status: "pass", score: 1, rationale: "Exactly one <h1>.", rationale_ko: "<h1>\uC774 \uC815\uD655\uD788 1\uAC1C\uC785\uB2C8\uB2E4." };
1500
1585
  if (n === 0) {
1501
1586
  return {
1502
1587
  status: "fail",
1503
1588
  score: 0,
1504
1589
  rationale: "No <h1> on the page.",
1590
+ rationale_ko: "\uD398\uC774\uC9C0\uC5D0 <h1>\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.",
1505
1591
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules/cnt.single-h1.md"
1506
1592
  };
1507
1593
  }
1508
1594
  return {
1509
1595
  status: "warn",
1510
1596
  score: Math.max(0.3, 1 / n),
1511
- rationale: `Found ${n} <h1> tags; prefer one primary heading.`
1597
+ rationale: `Found ${n} <h1> tags; prefer one primary heading.`,
1598
+ rationale_ko: `<h1>\uC774 ${n}\uAC1C \uC788\uC2B5\uB2C8\uB2E4. \uB300\uD45C \uC81C\uBAA9 1\uAC1C\uB9CC \uC0AC\uC6A9\uD558\uC138\uC694.`
1512
1599
  };
1513
1600
  }
1514
1601
  });
@@ -1524,6 +1611,7 @@ var headingHierarchyRule = defineRule({
1524
1611
  effort: "medium",
1525
1612
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntheading-hierarchy",
1526
1613
  title: "Heading levels do not skip",
1614
+ title_ko: "\uC81C\uBAA9 \uB2E8\uACC4 \uC21C\uC11C \uC900\uC218 \uC5EC\uBD80",
1527
1615
  description: "Going from H2 directly to H4 breaks the outline AI engines use to segment content.",
1528
1616
  run(ctx) {
1529
1617
  const levels = [];
@@ -1533,7 +1621,7 @@ var headingHierarchyRule = defineRule({
1533
1621
  if (m?.[1]) levels.push(parseInt(m[1], 10));
1534
1622
  });
1535
1623
  if (levels.length === 0) {
1536
- return { status: "skip", score: 0, rationale: "No headings found." };
1624
+ return { status: "skip", score: 0, rationale: "No headings found.", rationale_ko: "\uC81C\uBAA9 \uD0DC\uADF8\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
1537
1625
  }
1538
1626
  const skips = [];
1539
1627
  for (let i = 1; i < levels.length; i++) {
@@ -1542,12 +1630,13 @@ var headingHierarchyRule = defineRule({
1542
1630
  if (curr > prev + 1) skips.push({ from: prev, to: curr });
1543
1631
  }
1544
1632
  if (skips.length === 0) {
1545
- return { status: "pass", score: 1, rationale: "No heading-level skips." };
1633
+ 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." };
1546
1634
  }
1547
1635
  return {
1548
1636
  status: "warn",
1549
1637
  score: Math.max(0.3, 1 - skips.length / levels.length),
1550
1638
  rationale: `${skips.length} heading skip(s) detected (e.g. h${skips[0].from}\u2192h${skips[0].to}).`,
1639
+ 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}).`,
1551
1640
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1552
1641
  };
1553
1642
  }
@@ -1564,11 +1653,12 @@ var imageAltRule = defineRule({
1564
1653
  effort: "medium",
1565
1654
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntimage-alt",
1566
1655
  title: "\u226580% of <img> have alt text",
1656
+ title_ko: "<img>\uC758 80% \uC774\uC0C1 alt \uD14D\uC2A4\uD2B8 \uBCF4\uC720 \uC5EC\uBD80",
1567
1657
  description: "Alt text gives AI engines a textual anchor for visual content and improves accessibility.",
1568
1658
  run(ctx) {
1569
1659
  const imgs = ctx.$("img");
1570
1660
  const total = imgs.length;
1571
- if (total === 0) return { status: "skip", score: 0, rationale: "No <img> on the page." };
1661
+ 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." };
1572
1662
  let withAlt = 0;
1573
1663
  imgs.each((_i, el) => {
1574
1664
  const alt = ctx.$(el).attr("alt");
@@ -1576,12 +1666,13 @@ var imageAltRule = defineRule({
1576
1666
  });
1577
1667
  const ratio = withAlt / total;
1578
1668
  if (ratio >= 0.8) {
1579
- return { status: "pass", score: 1, rationale: `${withAlt}/${total} images have alt (${Math.round(ratio * 100)}%).` };
1669
+ 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)}%).` };
1580
1670
  }
1581
1671
  return {
1582
1672
  status: "warn",
1583
1673
  score: ratio,
1584
1674
  rationale: `Only ${withAlt}/${total} images have alt text (${Math.round(ratio * 100)}%). Aim for \u226580%.`,
1675
+ 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.`,
1585
1676
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1586
1677
  };
1587
1678
  }
@@ -1598,11 +1689,12 @@ var tldrOrFaqRule = defineRule({
1598
1689
  effort: "medium",
1599
1690
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cnttldr-or-faq",
1600
1691
  title: "TL;DR summary or FAQ block",
1692
+ title_ko: "TL;DR \uC694\uC57D \uB610\uB294 FAQ \uBE14\uB85D \uC874\uC7AC \uC5EC\uBD80",
1601
1693
  description: 'AI engines strongly prefer content with a quotable summary or FAQ \u2014 it makes the page "citation-ready".',
1602
1694
  run(ctx) {
1603
1695
  for (const node of flattenJsonLd(ctx.jsonLd)) {
1604
1696
  if (getTypes(node).includes("FAQPage")) {
1605
- return { status: "pass", score: 1, rationale: "FAQPage schema present." };
1697
+ return { status: "pass", score: 1, rationale: "FAQPage schema present.", rationale_ko: "FAQPage \uC2A4\uD0A4\uB9C8\uAC00 \uC788\uC2B5\uB2C8\uB2E4." };
1606
1698
  }
1607
1699
  }
1608
1700
  const sel = [
@@ -1615,12 +1707,13 @@ var tldrOrFaqRule = defineRule({
1615
1707
  "[data-tldr]"
1616
1708
  ].join(", ");
1617
1709
  if (ctx.$(sel).length > 0) {
1618
- return { status: "pass", score: 0.85, rationale: "TL;DR / summary / FAQ region detected by selector." };
1710
+ 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." };
1619
1711
  }
1620
1712
  return {
1621
1713
  status: "warn",
1622
1714
  score: 0,
1623
1715
  rationale: "No TL;DR / summary / FAQ found; add one to boost AI citation odds.",
1716
+ 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.",
1624
1717
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1625
1718
  };
1626
1719
  }
@@ -1637,6 +1730,7 @@ var wordCountRule = defineRule({
1637
1730
  effort: "high",
1638
1731
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntword-count",
1639
1732
  title: "Page has enough body text",
1733
+ title_ko: "\uCDA9\uBD84\uD55C \uBCF8\uBB38 \uD14D\uC2A4\uD2B8 \uC5EC\uBD80",
1640
1734
  description: "Thin pages (under ~100 words) are rarely cited by AI engines. Aim for \u2265300 words of meaningful body copy.",
1641
1735
  run(ctx) {
1642
1736
  const $ = ctx.$;
@@ -1644,12 +1738,13 @@ var wordCountRule = defineRule({
1644
1738
  clone.find("script, style, noscript, nav, header, footer, aside").remove();
1645
1739
  const text = clone.text().replace(/\s+/g, " ").trim();
1646
1740
  const words = text ? text.split(" ").length : 0;
1647
- if (words >= 300) return { status: "pass", score: 1, rationale: `${words} words of body text.` };
1648
- if (words >= 100) return { status: "warn", score: 0.5, rationale: `Only ${words} words; aim for 300+.` };
1741
+ 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.` };
1742
+ 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.` };
1649
1743
  return {
1650
1744
  status: "fail",
1651
1745
  score: 0,
1652
1746
  rationale: `Only ${words} words; too thin to be cited.`,
1747
+ 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.`,
1653
1748
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1654
1749
  };
1655
1750
  }
@@ -1700,11 +1795,12 @@ var qaStructureRule = defineRule({
1700
1795
  effort: "medium",
1701
1796
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntqa-structure",
1702
1797
  title: "Content uses Q&A structure for answer extraction",
1798
+ title_ko: "\uB2F5\uBCC0 \uCD94\uCD9C\uC744 \uC704\uD55C Q&A \uAD6C\uC870 \uC0AC\uC6A9 \uC5EC\uBD80",
1703
1799
  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.",
1704
1800
  run(ctx) {
1705
1801
  for (const node of flattenJsonLd(ctx.jsonLd)) {
1706
1802
  if (getTypes(node).includes("FAQPage")) {
1707
- return { status: "pass", score: 1, rationale: "FAQPage JSON-LD provides explicit Q&A." };
1803
+ 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." };
1708
1804
  }
1709
1805
  }
1710
1806
  const questionHeadings = [];
@@ -1717,6 +1813,7 @@ var qaStructureRule = defineRule({
1717
1813
  status: "pass",
1718
1814
  score: 1,
1719
1815
  rationale: `${questionHeadings.length} question-style headings detected.`,
1816
+ rationale_ko: `\uC9C8\uBB38\uD615 \uC81C\uBAA9\uC774 ${questionHeadings.length}\uAC1C \uAC10\uC9C0\uB429\uB2C8\uB2E4.`,
1720
1817
  evidence: { headings: questionHeadings.slice(0, 5) }
1721
1818
  };
1722
1819
  }
@@ -1725,6 +1822,7 @@ var qaStructureRule = defineRule({
1725
1822
  status: "warn",
1726
1823
  score: 0.6,
1727
1824
  rationale: "1 question-style heading. Add a second to strengthen answer extraction.",
1825
+ 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.",
1728
1826
  evidence: { headings: questionHeadings },
1729
1827
  estimatedImpact: 1
1730
1828
  };
@@ -1733,6 +1831,7 @@ var qaStructureRule = defineRule({
1733
1831
  status: "warn",
1734
1832
  score: 0,
1735
1833
  rationale: "No question-style H2/H3 headings or FAQPage JSON-LD found.",
1834
+ rationale_ko: "\uC9C8\uBB38\uD615 H2/H3 \uC81C\uBAA9\uC774\uB098 FAQPage JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.",
1736
1835
  fixHint: 'Reframe at least 2 H2 headings as questions ("How do I\u2026?", "What is\u2026?") or add FAQPage JSON-LD.',
1737
1836
  estimatedImpact: 3
1738
1837
  };
@@ -1750,13 +1849,14 @@ var externalCitationsRule = defineRule({
1750
1849
  effort: "medium",
1751
1850
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntexternal-citations",
1752
1851
  title: "Content cites external sources",
1852
+ title_ko: "\uC678\uBD80 \uCD9C\uCC98 \uC778\uC6A9 \uC5EC\uBD80",
1753
1853
  description: "Outbound links to authoritative external sources are an E-E-A-T trust signal. AI engines treat well-cited pages as more credible.",
1754
1854
  run(ctx) {
1755
1855
  let host;
1756
1856
  try {
1757
1857
  host = new URL(ctx.finalUrl).hostname.toLowerCase();
1758
1858
  } catch {
1759
- return { status: "skip", score: 0, rationale: "Invalid finalUrl." };
1859
+ return { status: "skip", score: 0, rationale: "Invalid finalUrl.", rationale_ko: "\uC720\uD6A8\uD558\uC9C0 \uC54A\uC740 finalUrl\uC785\uB2C8\uB2E4." };
1760
1860
  }
1761
1861
  const seen = /* @__PURE__ */ new Set();
1762
1862
  ctx.$("main a[href], article a[href], body a[href]").each((_i, el) => {
@@ -1781,6 +1881,7 @@ var externalCitationsRule = defineRule({
1781
1881
  status: "pass",
1782
1882
  score: 1,
1783
1883
  rationale: `${count} distinct external host(s) cited (excluding nofollow).`,
1884
+ rationale_ko: `\uC678\uBD80 \uC0AC\uC774\uD2B8 ${count}\uAC1C\uB97C \uC778\uC6A9\uD569\uB2C8\uB2E4 (nofollow \uC81C\uC678).`,
1784
1885
  evidence: { hosts: [...seen].slice(0, 8) }
1785
1886
  };
1786
1887
  }
@@ -1789,6 +1890,7 @@ var externalCitationsRule = defineRule({
1789
1890
  status: "pass",
1790
1891
  score: 0.7,
1791
1892
  rationale: `${count} external host(s) cited. Aim for \u22653 for stronger E-E-A-T.`,
1893
+ 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.`,
1792
1894
  evidence: { hosts: [...seen] },
1793
1895
  estimatedImpact: 1
1794
1896
  };
@@ -1797,6 +1899,7 @@ var externalCitationsRule = defineRule({
1797
1899
  status: "warn",
1798
1900
  score: 0,
1799
1901
  rationale: "No external follow citations found in main content.",
1902
+ rationale_ko: "\uBCF8\uBB38\uC5D0 \uC678\uBD80 \uCD9C\uCC98 \uB9C1\uD06C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.",
1800
1903
  fixHint: "Cite at least one authoritative external source (research paper, official docs, news outlet).",
1801
1904
  estimatedImpact: 2
1802
1905
  };
@@ -1814,12 +1917,130 @@ var contentRules = [
1814
1917
  externalCitationsRule
1815
1918
  ];
1816
1919
 
1920
+ // src/rules/aeo/skill-md.ts
1921
+ var aeoSkillMdRule = defineRule({
1922
+ id: "aeo.skill-md",
1923
+ stableId: "aeo.skill-md",
1924
+ category: "aeo",
1925
+ group: "opportunity",
1926
+ weight: 3,
1927
+ impact: "high",
1928
+ effort: "low",
1929
+ docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#aeoskill-md",
1930
+ title: "skill.md is present",
1931
+ title_ko: "skill.md \uD30C\uC77C \uC874\uC7AC \uC5EC\uBD80",
1932
+ description: "A /skill.md file describes site capabilities so AI agents know what this site can do for them.",
1933
+ run(ctx) {
1934
+ if (ctx.skillMd !== null) {
1935
+ return {
1936
+ status: "pass",
1937
+ score: 1,
1938
+ rationale: "skill.md found at site root.",
1939
+ rationale_ko: "skill.md\uAC00 \uC0AC\uC774\uD2B8 \uB8E8\uD2B8\uC5D0 \uC874\uC7AC\uD569\uB2C8\uB2E4."
1940
+ };
1941
+ }
1942
+ return {
1943
+ status: "warn",
1944
+ score: 0,
1945
+ rationale: "No /skill.md found. Add one to describe your site capabilities to AI agents.",
1946
+ 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.",
1947
+ fixHint: "Create /skill.md listing what services, products, and capabilities this site offers."
1948
+ };
1949
+ }
1950
+ });
1951
+
1952
+ // src/rules/aeo/agent-permissions.ts
1953
+ var aeoAgentPermissionsRule = defineRule({
1954
+ id: "aeo.agent-permissions",
1955
+ stableId: "aeo.agent-permissions",
1956
+ category: "aeo",
1957
+ group: "opportunity",
1958
+ weight: 3,
1959
+ impact: "medium",
1960
+ effort: "low",
1961
+ docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#aeoagent-permissions",
1962
+ title: "agent-permissions.json is present",
1963
+ title_ko: "agent-permissions.json \uD30C\uC77C \uC874\uC7AC \uC5EC\uBD80",
1964
+ description: "Declares explicit read/summarize/cite/train permissions for AI agents.",
1965
+ run(ctx) {
1966
+ if (ctx.agentPermissions !== null) {
1967
+ return {
1968
+ status: "pass",
1969
+ score: 1,
1970
+ rationale: "agent-permissions.json found at site root.",
1971
+ rationale_ko: "agent-permissions.json\uC774 \uC0AC\uC774\uD2B8 \uB8E8\uD2B8\uC5D0 \uC874\uC7AC\uD569\uB2C8\uB2E4.",
1972
+ evidence: ctx.agentPermissions
1973
+ };
1974
+ }
1975
+ return {
1976
+ status: "warn",
1977
+ score: 0,
1978
+ rationale: "No /agent-permissions.json found. Add one to declare AI agent access policy.",
1979
+ 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.",
1980
+ fixHint: "Create /agent-permissions.json with read, summarize, cite, and train permission flags."
1981
+ };
1982
+ }
1983
+ });
1984
+
1985
+ // src/rules/aeo/token-length.ts
1986
+ var THRESHOLD_OPTIMAL = 15e3;
1987
+ var THRESHOLD_MAX = 25e3;
1988
+ var aeoTokenLengthRule = defineRule({
1989
+ id: "aeo.token-length",
1990
+ stableId: "aeo.token-length",
1991
+ category: "aeo",
1992
+ group: "diagnostic",
1993
+ weight: 4,
1994
+ impact: "medium",
1995
+ effort: "medium",
1996
+ docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#aeotoken-length",
1997
+ title: "Content token length within AI agent limits",
1998
+ title_ko: "\uCF58\uD150\uCE20 \uD1A0\uD070 \uC218 AI \uC5D0\uC774\uC804\uD2B8 \uAD8C\uC7A5 \uBC94\uC704",
1999
+ description: "Pages under 15K tokens are optimal for AI agents (per Addy Osmani's AEO guidance).",
2000
+ run(ctx) {
2001
+ const text = ctx.$("body").text();
2002
+ const tokenEstimate = Math.round(text.length / 3);
2003
+ const evidence = { tokenEstimate, thresholds: { optimal: THRESHOLD_OPTIMAL, max: THRESHOLD_MAX } };
2004
+ if (tokenEstimate <= THRESHOLD_OPTIMAL) {
2005
+ return {
2006
+ status: "pass",
2007
+ score: 1,
2008
+ rationale: `Estimated ~${tokenEstimate.toLocaleString()} tokens \u2014 within optimal range.`,
2009
+ rationale_ko: `\uC608\uC0C1 \uD1A0\uD070 \uC218 ~${tokenEstimate.toLocaleString()} \u2014 \uAD8C\uC7A5 \uBC94\uC704(15K) \uC774\uB0B4\uC785\uB2C8\uB2E4.`,
2010
+ evidence
2011
+ };
2012
+ }
2013
+ if (tokenEstimate <= THRESHOLD_MAX) {
2014
+ return {
2015
+ status: "warn",
2016
+ score: 0.5,
2017
+ rationale: `Estimated ~${tokenEstimate.toLocaleString()} tokens \u2014 exceeds 15K recommendation.`,
2018
+ rationale_ko: `\uC608\uC0C1 \uD1A0\uD070 \uC218 ~${tokenEstimate.toLocaleString()} \u2014 15K \uAD8C\uC7A5\uCE58\uB97C \uCD08\uACFC\uD569\uB2C8\uB2E4.`,
2019
+ fixHint: "Consider splitting content into shorter, focused pages.",
2020
+ evidence
2021
+ };
2022
+ }
2023
+ return {
2024
+ status: "fail",
2025
+ score: 0,
2026
+ rationale: `Estimated ~${tokenEstimate.toLocaleString()} tokens \u2014 exceeds 25K agent processing limit.`,
2027
+ 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.`,
2028
+ fixHint: "Split this page into multiple focused pages under 15K tokens.",
2029
+ evidence
2030
+ };
2031
+ }
2032
+ });
2033
+
2034
+ // src/rules/aeo/index.ts
2035
+ var aeoRules = [aeoSkillMdRule, aeoAgentPermissionsRule, aeoTokenLengthRule];
2036
+
1817
2037
  // src/rules/index.ts
1818
2038
  var defaultRules = [
1819
2039
  ...crawlerRules,
1820
2040
  ...structuredDataRules,
1821
2041
  ...citationRules,
1822
- ...contentRules
2042
+ ...contentRules,
2043
+ ...aeoRules
1823
2044
  ];
1824
2045
 
1825
2046
  // src/config.ts
@@ -2015,7 +2236,8 @@ var CATEGORY_LABELS = {
2015
2236
  crawler: "AI Crawler Access",
2016
2237
  "structured-data": "Structured Data",
2017
2238
  citation: "Citation Signals",
2018
- content: "Content Structure"
2239
+ content: "Content Structure",
2240
+ aeo: "AEO Stack"
2019
2241
  };
2020
2242
  function scoreBadge(score) {
2021
2243
  const color = score >= 85 ? "brightgreen" : score >= 60 ? "yellow" : "red";
@@ -2310,7 +2532,8 @@ var CATEGORY_LABELS2 = {
2310
2532
  crawler: "AI Crawler Access",
2311
2533
  "structured-data": "Structured Data",
2312
2534
  citation: "Citation Signals",
2313
- content: "Content Structure"
2535
+ content: "Content Structure",
2536
+ aeo: "AEO Stack"
2314
2537
  };
2315
2538
  function colorScore(score) {
2316
2539
  if (score >= 85) return kleur.green().bold(`${score}`);
@@ -2410,7 +2633,8 @@ var CATEGORY_LABELS3 = {
2410
2633
  crawler: "AI Crawler Access",
2411
2634
  "structured-data": "Structured Data",
2412
2635
  citation: "Citation Signals",
2413
- content: "Content Structure"
2636
+ content: "Content Structure",
2637
+ aeo: "AEO Stack"
2414
2638
  };
2415
2639
  var IMPACT_ORDER = {
2416
2640
  critical: 4,
@@ -2493,13 +2717,15 @@ function partitionResults(report) {
2493
2717
  crawler: [],
2494
2718
  "structured-data": [],
2495
2719
  citation: [],
2496
- content: []
2720
+ content: [],
2721
+ aeo: []
2497
2722
  };
2498
2723
  const passed = {
2499
2724
  crawler: [],
2500
2725
  "structured-data": [],
2501
2726
  citation: [],
2502
- content: []
2727
+ content: [],
2728
+ aeo: []
2503
2729
  };
2504
2730
  for (const cat of Object.keys(report.categories)) {
2505
2731
  for (const r of report.categories[cat].results) {