geo-checker 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -255,10 +255,12 @@ async function buildContext(url, opts = {}) {
255
255
  "Site appears to be JS-rendered (sparse body + SPA root element). Re-run with --render for accurate results."
256
256
  );
257
257
  }
258
- const [robotsRaw, llmsRaw, llmsFullRaw] = await Promise.all([
258
+ const [robotsRaw, llmsRaw, llmsFullRaw, skillMdRaw, agentPermissionsRaw] = await Promise.all([
259
259
  fetchText(`${origin}/robots.txt`, opts),
260
260
  fetchText(`${origin}/llms.txt`, opts),
261
- fetchText(`${origin}/llms-full.txt`, opts)
261
+ fetchText(`${origin}/llms-full.txt`, opts),
262
+ fetchText(`${origin}/skill.md`, opts),
263
+ fetchText(`${origin}/agent-permissions.json`, opts)
262
264
  ]);
263
265
  let sitemapUrl = null;
264
266
  const robots = robotsRaw ? parseRobots(robotsRaw) : null;
@@ -266,6 +268,13 @@ async function buildContext(url, opts = {}) {
266
268
  if (!sitemapUrl) sitemapUrl = `${origin}/sitemap.xml`;
267
269
  const sitemapRaw = await fetchText(sitemapUrl, opts);
268
270
  const sitemap = sitemapRaw ? parseSitemap(sitemapRaw) : null;
271
+ let agentPermissions = null;
272
+ if (agentPermissionsRaw && agentPermissionsRaw.trim().length > 0) {
273
+ try {
274
+ agentPermissions = JSON.parse(agentPermissionsRaw);
275
+ } catch {
276
+ }
277
+ }
269
278
  return {
270
279
  url,
271
280
  finalUrl,
@@ -280,7 +289,9 @@ async function buildContext(url, opts = {}) {
280
289
  jsonLd: extractJsonLd($),
281
290
  renderMode,
282
291
  fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
283
- warnings
292
+ warnings,
293
+ skillMd: skillMdRaw && skillMdRaw.trim().length > 0 ? skillMdRaw : null,
294
+ agentPermissions
284
295
  };
285
296
  }
286
297
 
@@ -289,10 +300,11 @@ function defineRule(rule) {
289
300
  return rule;
290
301
  }
291
302
  var CATEGORY_WEIGHTS = {
292
- crawler: 25,
293
- "structured-data": 30,
294
- citation: 25,
295
- content: 20
303
+ crawler: 20,
304
+ "structured-data": 25,
305
+ citation: 20,
306
+ content: 15,
307
+ aeo: 20
296
308
  };
297
309
 
298
310
  // src/engine.ts
@@ -306,7 +318,8 @@ async function runRules(ctx, rules, opts = {}) {
306
318
  crawler: { score: 0, weight: weights.crawler, results: [] },
307
319
  "structured-data": { score: 0, weight: weights["structured-data"], results: [] },
308
320
  citation: { score: 0, weight: weights.citation, results: [] },
309
- content: { score: 0, weight: weights.content, results: [] }
321
+ content: { score: 0, weight: weights.content, results: [] },
322
+ aeo: { score: 0, weight: weights.aeo, results: [] }
310
323
  };
311
324
  for (const rule of rules) {
312
325
  if (onlySet && !onlySet.has(rule.id) && (!rule.stableId || !onlySet.has(rule.stableId))) continue;
@@ -331,6 +344,7 @@ async function runRules(ctx, rules, opts = {}) {
331
344
  durationMs
332
345
  };
333
346
  if (rule.stableId !== void 0) entry.stableId = rule.stableId;
347
+ if (rule.title_ko !== void 0) entry.title_ko = rule.title_ko;
334
348
  if (rule.group !== void 0) entry.group = rule.group;
335
349
  if (rule.impact !== void 0) entry.impact = rule.impact;
336
350
  if (rule.effort !== void 0) entry.effort = rule.effort;
@@ -393,13 +407,15 @@ var httpsRule = defineRule({
393
407
  effort: "medium",
394
408
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerhttps",
395
409
  title: "Site is served over HTTPS",
410
+ title_ko: "\uC0AC\uC774\uD2B8\uAC00 HTTPS\uB85C \uC81C\uACF5\uB428",
396
411
  description: "AI crawlers treat HTTPS pages as more trustworthy and some skip plain HTTP entirely.",
397
412
  run(ctx) {
398
413
  const isHttps = ctx.finalUrl.startsWith("https://");
399
- return isHttps ? { status: "pass", score: 1, rationale: "Final URL uses HTTPS." } : {
414
+ return isHttps ? { status: "pass", score: 1, rationale: "Final URL uses HTTPS.", rationale_ko: "\uCD5C\uC885 URL\uC774 HTTPS\uB97C \uC0AC\uC6A9\uD569\uB2C8\uB2E4." } : {
400
415
  status: "fail",
401
416
  score: 0,
402
417
  rationale: "Final URL does not use HTTPS. Redirect HTTP \u2192 HTTPS site-wide.",
418
+ 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.",
403
419
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
404
420
  };
405
421
  }
@@ -416,15 +432,17 @@ var robotsReachableRule = defineRule({
416
432
  effort: "low",
417
433
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerrobots-reachable",
418
434
  title: "robots.txt is reachable",
435
+ title_ko: "robots.txt \uC811\uADFC \uAC00\uB2A5 \uC5EC\uBD80",
419
436
  description: "A reachable robots.txt lets crawlers confirm their permissions; missing file is treated as allow-all but blocks explicit signalling.",
420
437
  run(ctx) {
421
438
  if (ctx.robots) {
422
- return { status: "pass", score: 1, rationale: "robots.txt returned successfully." };
439
+ 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." };
423
440
  }
424
441
  return {
425
442
  status: "warn",
426
443
  score: 0.3,
427
444
  rationale: "robots.txt is missing. Add one even if empty to explicitly signal crawl policy.",
445
+ 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.",
428
446
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
429
447
  };
430
448
  }
@@ -472,13 +490,15 @@ var robotsAiAllowRule = defineRule({
472
490
  effort: "low",
473
491
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerrobots-ai-allow",
474
492
  title: "AI crawlers are allowed",
493
+ title_ko: "AI \uD06C\uB864\uB7EC \uC811\uADFC \uD5C8\uC6A9 \uC5EC\uBD80",
475
494
  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.",
476
495
  run(ctx) {
477
496
  if (!ctx.robots) {
478
497
  return {
479
498
  status: "warn",
480
499
  score: 0.5,
481
- rationale: "robots.txt missing; AI crawlers default to allow, but explicit allow is recommended."
500
+ rationale: "robots.txt missing; AI crawlers default to allow, but explicit allow is recommended.",
501
+ 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."
482
502
  };
483
503
  }
484
504
  const path = new URL(ctx.finalUrl).pathname || "/";
@@ -496,6 +516,7 @@ var robotsAiAllowRule = defineRule({
496
516
  status: "fail",
497
517
  score: Math.max(0, 1 - blocked.length / AI_BOTS.length),
498
518
  rationale: `Blocked: ${blocked.join(", ")}. Remove the Disallow or add an explicit Allow for these user-agents.`,
519
+ 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.`,
499
520
  evidence: { blocked, mentioned, totalBots: AI_BOTS.length },
500
521
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
501
522
  };
@@ -504,13 +525,15 @@ var robotsAiAllowRule = defineRule({
504
525
  return {
505
526
  status: "warn",
506
527
  score: 0.6,
507
- rationale: `All ${AI_BOTS.length} AI crawlers reach the page via default rules, but none are explicitly listed. Consider explicit Allow entries.`
528
+ rationale: `All ${AI_BOTS.length} AI crawlers reach the page via default rules, but none are explicitly listed. Consider explicit Allow entries.`,
529
+ 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.`
508
530
  };
509
531
  }
510
532
  return {
511
533
  status: "pass",
512
534
  score: 1,
513
535
  rationale: `All ${AI_BOTS.length} AI crawlers can reach the page; ${mentioned.length} explicitly listed.`,
536
+ 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.`,
514
537
  evidence: { mentioned, totalBots: AI_BOTS.length }
515
538
  };
516
539
  }
@@ -527,15 +550,17 @@ var llmsTxtPresentRule = defineRule({
527
550
  effort: "medium",
528
551
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerllms-txt-present",
529
552
  title: "llms.txt is present",
553
+ title_ko: "llms.txt \uD30C\uC77C \uC874\uC7AC \uC5EC\uBD80",
530
554
  description: "An /llms.txt file at the site root gives AI assistants a curated map of the most citation-worthy pages.",
531
555
  run(ctx) {
532
556
  if (ctx.llmsTxt) {
533
- return { status: "pass", score: 1, rationale: "llms.txt found at site root." };
557
+ 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." };
534
558
  }
535
559
  return {
536
560
  status: "warn",
537
561
  score: 0,
538
562
  rationale: "No /llms.txt found. Add one to curate the pages AI assistants should read.",
563
+ 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.",
539
564
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
540
565
  };
541
566
  }
@@ -552,10 +577,11 @@ var llmsTxtWellformedRule = defineRule({
552
577
  effort: "low",
553
578
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerllms-txt-wellformed",
554
579
  title: "llms.txt follows the spec",
580
+ title_ko: "llms.txt \uC2A4\uD399 \uC900\uC218 \uC5EC\uBD80",
555
581
  description: "Must start with an H1 project title, then a brief summary, then at least one H2 section containing link items.",
556
582
  run(ctx) {
557
583
  if (!ctx.llmsTxt) {
558
- return { status: "skip", score: 0, rationale: "No llms.txt to validate." };
584
+ return { status: "skip", score: 0, rationale: "No llms.txt to validate.", rationale_ko: "\uAC80\uC99D\uD560 llms.txt\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
559
585
  }
560
586
  const check = isLlmsTxtWellFormed(ctx.llmsTxt);
561
587
  if (check.ok) {
@@ -563,13 +589,15 @@ var llmsTxtWellformedRule = defineRule({
563
589
  return {
564
590
  status: "pass",
565
591
  score: 1,
566
- rationale: `Well-formed with ${ctx.llmsTxt.sections.length} section(s) and ${totalLinks} link(s).`
592
+ rationale: `Well-formed with ${ctx.llmsTxt.sections.length} section(s) and ${totalLinks} link(s).`,
593
+ rationale_ko: `\uC2A4\uD399\uC5D0 \uB9DE\uAC8C \uC791\uC131\uB428 (\uC139\uC158 ${ctx.llmsTxt.sections.length}\uAC1C, \uB9C1\uD06C ${totalLinks}\uAC1C).`
567
594
  };
568
595
  }
569
596
  return {
570
597
  status: "warn",
571
598
  score: 0.3,
572
599
  rationale: `llms.txt does not fully match the spec: ${check.reason}.`,
600
+ rationale_ko: `llms.txt\uAC00 \uC2A4\uD399\uC744 \uC644\uC804\uD788 \uB530\uB974\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4: ${check.reason}.`,
573
601
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
574
602
  };
575
603
  }
@@ -586,13 +614,15 @@ var llmsFullTxtRule = defineRule({
586
614
  effort: "medium",
587
615
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlerllms-full-txt",
588
616
  title: "llms-full.txt provides full-content mirror",
617
+ title_ko: "llms-full.txt \uC804\uCCB4 \uCF58\uD150\uCE20 \uBBF8\uB7EC \uC81C\uACF5 \uC5EC\uBD80",
589
618
  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.",
590
619
  run(ctx) {
591
620
  if (ctx.llmsFullTxt && ctx.llmsFullTxt.length > 200) {
592
621
  return {
593
622
  status: "pass",
594
623
  score: 1,
595
- rationale: `/llms-full.txt found (${ctx.llmsFullTxt.length.toLocaleString()} chars).`
624
+ rationale: `/llms-full.txt found (${ctx.llmsFullTxt.length.toLocaleString()} chars).`,
625
+ rationale_ko: `/llms-full.txt\uAC00 \uC874\uC7AC\uD569\uB2C8\uB2E4 (${ctx.llmsFullTxt.length.toLocaleString()}\uC790).`
596
626
  };
597
627
  }
598
628
  if (ctx.llmsFullTxt) {
@@ -600,6 +630,7 @@ var llmsFullTxtRule = defineRule({
600
630
  status: "warn",
601
631
  score: 0.5,
602
632
  rationale: `/llms-full.txt found but very short (${ctx.llmsFullTxt.length} chars). Consider expanding with page bodies.`,
633
+ 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.`,
603
634
  fixHint: "Mirror full article bodies into /llms-full.txt so AI assistants can quote without re-crawling."
604
635
  };
605
636
  }
@@ -607,6 +638,7 @@ var llmsFullTxtRule = defineRule({
607
638
  status: "warn",
608
639
  score: 0,
609
640
  rationale: "No /llms-full.txt found. Adding one lets AI assistants ingest the full corpus in a single request.",
641
+ 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.",
610
642
  fixHint: "Publish /llms-full.txt alongside /llms.txt with the full body text of your top pages.",
611
643
  estimatedImpact: 1
612
644
  };
@@ -624,19 +656,22 @@ var sitemapPresentRule = defineRule({
624
656
  effort: "low",
625
657
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#crawlersitemap-present",
626
658
  title: "sitemap.xml is present",
659
+ title_ko: "sitemap.xml \uC874\uC7AC \uC5EC\uBD80",
627
660
  description: "A sitemap helps AI crawlers discover and prioritise pages; many crawlers short-circuit discovery without one.",
628
661
  run(ctx) {
629
662
  if (ctx.sitemap && ctx.sitemap.urls.length > 0) {
630
663
  return {
631
664
  status: "pass",
632
665
  score: 1,
633
- rationale: `Sitemap found with ${ctx.sitemap.urls.length} URL(s).`
666
+ rationale: `Sitemap found with ${ctx.sitemap.urls.length} URL(s).`,
667
+ rationale_ko: `\uC0AC\uC774\uD2B8\uB9F5\uC5D0 URL\uC774 ${ctx.sitemap.urls.length}\uAC1C \uC788\uC2B5\uB2C8\uB2E4.`
634
668
  };
635
669
  }
636
670
  return {
637
671
  status: "warn",
638
672
  score: 0.2,
639
673
  rationale: "No sitemap.xml found (checked /sitemap.xml and Sitemap: directive in robots.txt).",
674
+ 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).",
640
675
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
641
676
  };
642
677
  }
@@ -664,15 +699,17 @@ var jsonLdPresentRule = defineRule({
664
699
  effort: "medium",
665
700
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdjsonld-present",
666
701
  title: "JSON-LD structured data is present",
702
+ title_ko: "JSON-LD \uAD6C\uC870\uD654 \uB370\uC774\uD130 \uC874\uC7AC \uC5EC\uBD80",
667
703
  description: 'At least one <script type="application/ld+json"> block is the primary way AI engines map your page to an entity.',
668
704
  run(ctx) {
669
705
  if (ctx.jsonLd.length > 0) {
670
- return { status: "pass", score: 1, rationale: `Found ${ctx.jsonLd.length} JSON-LD block(s).` };
706
+ 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.` };
671
707
  }
672
708
  return {
673
709
  status: "fail",
674
710
  score: 0,
675
711
  rationale: "No JSON-LD blocks found. Add schema.org structured data.",
712
+ 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.",
676
713
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
677
714
  };
678
715
  }
@@ -748,20 +785,22 @@ var jsonLdValidJsonRule = defineRule({
748
785
  effort: "low",
749
786
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdjsonld-valid-json",
750
787
  title: "JSON-LD blocks parse as valid JSON",
788
+ title_ko: "JSON-LD \uBE14\uB85D\uC758 JSON \uC720\uD6A8\uC131",
751
789
  description: "Malformed JSON in an ld+json block is silently ignored by most consumers \u2014 a costly silent failure.",
752
790
  run(ctx) {
753
791
  if (ctx.jsonLd.length === 0) {
754
- return { status: "skip", score: 0, rationale: "No JSON-LD to validate." };
792
+ return { status: "skip", score: 0, rationale: "No JSON-LD to validate.", rationale_ko: "\uAC80\uC99D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
755
793
  }
756
794
  if (hasParseError(ctx.jsonLd)) {
757
795
  return {
758
796
  status: "fail",
759
797
  score: 0,
760
798
  rationale: "One or more JSON-LD blocks failed to parse.",
799
+ rationale_ko: "JSON-LD \uBE14\uB85D \uD558\uB098 \uC774\uC0C1\uC744 \uD30C\uC2F1\uD558\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.",
761
800
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
762
801
  };
763
802
  }
764
- return { status: "pass", score: 1, rationale: "All JSON-LD blocks parse cleanly." };
803
+ 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." };
765
804
  }
766
805
  });
767
806
 
@@ -776,10 +815,11 @@ var schemaTypeRecognizedRule = defineRule({
776
815
  effort: "low",
777
816
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdschema-type-recognized",
778
817
  title: "Schema.org @type is a recognised kind",
818
+ title_ko: "Schema.org @type \uC778\uC2DD \uAC00\uB2A5 \uC5EC\uBD80",
779
819
  description: "AI engines match pages against well-known types (Article, Product, FAQPage...). Obscure types weaken the signal.",
780
820
  run(ctx) {
781
821
  if (ctx.jsonLd.length === 0) {
782
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
822
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
783
823
  }
784
824
  const nodes = flattenJsonLd(ctx.jsonLd);
785
825
  const recognized = /* @__PURE__ */ new Set();
@@ -795,6 +835,7 @@ var schemaTypeRecognizedRule = defineRule({
795
835
  status: "pass",
796
836
  score: 1,
797
837
  rationale: `Recognised: ${[...recognized].join(", ")}.`,
838
+ rationale_ko: `\uC778\uC2DD\uB41C \uD0C0\uC785: ${[...recognized].join(", ")}.`,
798
839
  evidence: { recognized: [...recognized], all: [...seenTypes] }
799
840
  };
800
841
  }
@@ -802,6 +843,7 @@ var schemaTypeRecognizedRule = defineRule({
802
843
  status: "warn",
803
844
  score: 0.3,
804
845
  rationale: `No recognised schema.org types. Saw: ${[...seenTypes].join(", ") || "(none)"}.`,
846
+ 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)"}.`,
805
847
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
806
848
  };
807
849
  }
@@ -818,10 +860,11 @@ var requiredFieldsRule = defineRule({
818
860
  effort: "medium",
819
861
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdrequired-fields",
820
862
  title: "Required fields for recognised types are set",
863
+ title_ko: "\uC778\uC2DD\uB41C \uD0C0\uC785\uC758 \uD544\uC218 \uD544\uB4DC \uCDA9\uC871 \uC5EC\uBD80",
821
864
  description: "Article needs headline/author/datePublished, FAQPage needs mainEntity, Product needs offers, etc.",
822
865
  run(ctx) {
823
866
  if (ctx.jsonLd.length === 0) {
824
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
867
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
825
868
  }
826
869
  const nodes = flattenJsonLd(ctx.jsonLd);
827
870
  const missing = [];
@@ -840,14 +883,16 @@ var requiredFieldsRule = defineRule({
840
883
  return {
841
884
  status: "skip",
842
885
  score: 0,
843
- rationale: "No types with known required fields were found."
886
+ rationale: "No types with known required fields were found.",
887
+ rationale_ko: "\uD544\uC218 \uD544\uB4DC\uAC00 \uC815\uC758\uB41C \uD0C0\uC785\uC774 \uC5C6\uC2B5\uB2C8\uB2E4."
844
888
  };
845
889
  }
846
890
  if (missing.length === 0) {
847
891
  return {
848
892
  status: "pass",
849
893
  score: 1,
850
- rationale: `Required fields set on ${checked.length} node(s).`
894
+ rationale: `Required fields set on ${checked.length} node(s).`,
895
+ rationale_ko: `${checked.length}\uAC1C \uB178\uB4DC\uC758 \uD544\uC218 \uD544\uB4DC\uAC00 \uBAA8\uB450 \uCDA9\uC871\uB429\uB2C8\uB2E4.`
851
896
  };
852
897
  }
853
898
  const msg = missing.map((m) => `${m.type}.${m.field}`).join(", ");
@@ -855,6 +900,7 @@ var requiredFieldsRule = defineRule({
855
900
  status: "fail",
856
901
  score: Math.max(0, 1 - missing.length / (checked.length * 2)),
857
902
  rationale: `Missing required fields: ${msg}.`,
903
+ rationale_ko: `\uB204\uB77D\uB41C \uD544\uC218 \uD544\uB4DC: ${msg}.`,
858
904
  evidence: missing,
859
905
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
860
906
  };
@@ -872,10 +918,11 @@ var microdataFallbackRule = defineRule({
872
918
  effort: "medium",
873
919
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdmicrodata-fallback",
874
920
  title: "Microdata or RDFa fallback when JSON-LD is missing",
921
+ title_ko: "JSON-LD \uC5C6\uC744 \uB54C Microdata/RDFa \uB300\uCCB4 \uC5EC\uBD80",
875
922
  description: "If JSON-LD is absent, inline microdata (itemscope/itemtype) or RDFa still gives some structured signal.",
876
923
  run(ctx) {
877
924
  if (ctx.jsonLd.length > 0) {
878
- return { status: "skip", score: 0, rationale: "JSON-LD is present; fallback not needed." };
925
+ 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." };
879
926
  }
880
927
  const microdata = ctx.$("[itemscope][itemtype]").length;
881
928
  const rdfa = ctx.$("[typeof][vocab], [typeof][property]").length;
@@ -883,13 +930,15 @@ var microdataFallbackRule = defineRule({
883
930
  return {
884
931
  status: "pass",
885
932
  score: 1,
886
- rationale: `Found ${microdata} microdata and ${rdfa} RDFa nodes.`
933
+ rationale: `Found ${microdata} microdata and ${rdfa} RDFa nodes.`,
934
+ rationale_ko: `Microdata ${microdata}\uAC1C, RDFa ${rdfa}\uAC1C \uBC1C\uACAC\uB429\uB2C8\uB2E4.`
887
935
  };
888
936
  }
889
937
  return {
890
938
  status: "fail",
891
939
  score: 0,
892
940
  rationale: "No structured data at all (no JSON-LD, no microdata, no RDFa).",
941
+ rationale_ko: "\uAD6C\uC870\uD654 \uB370\uC774\uD130\uAC00 \uC804\uD600 \uC5C6\uC2B5\uB2C8\uB2E4 (JSON-LD, Microdata, RDFa \uBAA8\uB450 \uC5C6\uC74C).",
893
942
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
894
943
  };
895
944
  }
@@ -907,10 +956,11 @@ var noDuplicateTypesRule = defineRule({
907
956
  effort: "low",
908
957
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdno-duplicate-types",
909
958
  title: "No conflicting duplicate @types",
959
+ title_ko: "@type \uC911\uBCF5 \uCDA9\uB3CC \uC5C6\uC74C",
910
960
  description: "Multiple competing entities of the same primary type (e.g. two Articles) confuse the engine about which one represents the page.",
911
961
  run(ctx) {
912
962
  if (ctx.jsonLd.length === 0) {
913
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
963
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
914
964
  }
915
965
  const counts = /* @__PURE__ */ new Map();
916
966
  for (const node of flattenJsonLd(ctx.jsonLd)) {
@@ -920,12 +970,13 @@ var noDuplicateTypesRule = defineRule({
920
970
  }
921
971
  const dupes = [...counts.entries()].filter(([, n]) => n > 1);
922
972
  if (dupes.length === 0) {
923
- return { status: "pass", score: 1, rationale: "No duplicate primary types." };
973
+ return { status: "pass", score: 1, rationale: "No duplicate primary types.", rationale_ko: "\uC911\uBCF5\uB41C \uAE30\uBCF8 \uD0C0\uC785\uC774 \uC5C6\uC2B5\uB2C8\uB2E4." };
924
974
  }
925
975
  return {
926
976
  status: "warn",
927
977
  score: 0.4,
928
978
  rationale: `Duplicate primary types: ${dupes.map(([t, n]) => `${t}\xD7${n}`).join(", ")}.`,
979
+ rationale_ko: `\uC911\uBCF5\uB41C \uAE30\uBCF8 \uD0C0\uC785: ${dupes.map(([t, n]) => `${t}\xD7${n}`).join(", ")}.`,
929
980
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
930
981
  };
931
982
  }
@@ -973,10 +1024,11 @@ var sameAsEntityRule = defineRule({
973
1024
  effort: "medium",
974
1025
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdsameas-entity",
975
1026
  title: "Entity nodes link the knowledge graph via sameAs",
1027
+ title_ko: "sameAs\uB85C \uC9C0\uC2DD \uADF8\uB798\uD504 \uC5F0\uACB0 \uC5EC\uBD80",
976
1028
  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).",
977
1029
  run(ctx) {
978
1030
  if (ctx.jsonLd.length === 0) {
979
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
1031
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
980
1032
  }
981
1033
  const nodes = flattenJsonLd(ctx.jsonLd);
982
1034
  const entities = nodes.filter((n) => getTypes(n).some((t) => ENTITY_TYPES.includes(t)));
@@ -984,7 +1036,8 @@ var sameAsEntityRule = defineRule({
984
1036
  return {
985
1037
  status: "skip",
986
1038
  score: 0,
987
- rationale: "No Organization/Person/LocalBusiness/Brand entity to link."
1039
+ rationale: "No Organization/Person/LocalBusiness/Brand entity to link.",
1040
+ rationale_ko: "\uC5F0\uACB0\uD560 Organization/Person/LocalBusiness/Brand \uC5D4\uD2F0\uD2F0\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4."
988
1041
  };
989
1042
  }
990
1043
  let bestScore = 0;
@@ -1008,6 +1061,7 @@ var sameAsEntityRule = defineRule({
1008
1061
  status: "pass",
1009
1062
  score: 1,
1010
1063
  rationale: `Entity links ${bestEvidence.trusted} trusted knowledge-graph hosts via sameAs.`,
1064
+ 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.`,
1011
1065
  evidence: bestEvidence
1012
1066
  };
1013
1067
  }
@@ -1016,6 +1070,7 @@ var sameAsEntityRule = defineRule({
1016
1070
  status: "pass",
1017
1071
  score: bestScore,
1018
1072
  rationale: `Entity has 1 trusted sameAs link. Add Wikipedia/Wikidata for stronger E-E-A-T.`,
1073
+ 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.",
1019
1074
  evidence: bestEvidence,
1020
1075
  estimatedImpact: 1
1021
1076
  };
@@ -1025,6 +1080,7 @@ var sameAsEntityRule = defineRule({
1025
1080
  status: "warn",
1026
1081
  score: bestScore,
1027
1082
  rationale: "Entity declares sameAs but no trusted knowledge-graph hosts (Wikipedia/Wikidata/LinkedIn).",
1083
+ 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.",
1028
1084
  evidence: bestEvidence,
1029
1085
  fixHint: "Add Wikipedia/Wikidata/LinkedIn URLs to your Organization sameAs[].",
1030
1086
  estimatedImpact: 2
@@ -1034,6 +1090,7 @@ var sameAsEntityRule = defineRule({
1034
1090
  status: "warn",
1035
1091
  score: 0,
1036
1092
  rationale: `${entities.length} entity node(s) found but none declare sameAs links.`,
1093
+ rationale_ko: `\uC5D4\uD2F0\uD2F0 \uB178\uB4DC\uAC00 ${entities.length}\uAC1C \uC788\uC9C0\uB9CC sameAs \uB9C1\uD06C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.`,
1037
1094
  fixHint: 'Add sameAs:["https://en.wikipedia.org/wiki/...", "https://www.linkedin.com/company/..."] to your Organization JSON-LD.',
1038
1095
  estimatedImpact: 3
1039
1096
  };
@@ -1065,15 +1122,16 @@ var breadcrumbValidRule = defineRule({
1065
1122
  effort: "medium",
1066
1123
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#sdbreadcrumb-valid",
1067
1124
  title: "BreadcrumbList items declare position, name, and item",
1125
+ title_ko: "BreadcrumbList \uD56D\uBAA9\uC758 \uD544\uC218 \uD544\uB4DC \uCDA9\uC871 \uC5EC\uBD80",
1068
1126
  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.",
1069
1127
  run(ctx) {
1070
1128
  if (ctx.jsonLd.length === 0) {
1071
- return { status: "skip", score: 0, rationale: "No JSON-LD to analyse." };
1129
+ return { status: "skip", score: 0, rationale: "No JSON-LD to analyse.", rationale_ko: "\uBD84\uC11D\uD560 JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
1072
1130
  }
1073
1131
  const nodes = flattenJsonLd(ctx.jsonLd);
1074
1132
  const breadcrumbs = nodes.filter((n) => getTypes(n).includes("BreadcrumbList"));
1075
1133
  if (breadcrumbs.length === 0) {
1076
- return { status: "skip", score: 0, rationale: "No BreadcrumbList present." };
1134
+ return { status: "skip", score: 0, rationale: "No BreadcrumbList present.", rationale_ko: "BreadcrumbList\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
1077
1135
  }
1078
1136
  const allIssues = [];
1079
1137
  let totalItems = 0;
@@ -1086,7 +1144,8 @@ var breadcrumbValidRule = defineRule({
1086
1144
  return {
1087
1145
  status: "pass",
1088
1146
  score: 1,
1089
- rationale: `BreadcrumbList(s) valid (${totalItems} items).`
1147
+ rationale: `BreadcrumbList(s) valid (${totalItems} items).`,
1148
+ rationale_ko: `BreadcrumbList\uAC00 \uC720\uD6A8\uD569\uB2C8\uB2E4 (\uD56D\uBAA9 ${totalItems}\uAC1C).`
1090
1149
  };
1091
1150
  }
1092
1151
  const fatalCount = allIssues.length;
@@ -1096,6 +1155,7 @@ var breadcrumbValidRule = defineRule({
1096
1155
  status: score < 0.5 ? "fail" : "warn",
1097
1156
  score,
1098
1157
  rationale: `${fatalCount} breadcrumb item(s) missing required fields.`,
1158
+ rationale_ko: `breadcrumb \uD56D\uBAA9 ${fatalCount}\uAC1C\uC5D0 \uD544\uC218 \uD544\uB4DC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.`,
1099
1159
  evidence: allIssues.slice(0, 5),
1100
1160
  fixHint: 'Each itemListElement needs { "@type": "ListItem", position: N, name, item }.',
1101
1161
  estimatedImpact: Math.round(2 * (1 - score))
@@ -1126,6 +1186,7 @@ var titleRule = defineRule({
1126
1186
  effort: "low",
1127
1187
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cittitle",
1128
1188
  title: "<title> is set with a reasonable length",
1189
+ title_ko: "<title> \uD0DC\uADF8 \uC801\uC815 \uAE38\uC774 \uC124\uC815 \uC5EC\uBD80",
1129
1190
  description: "The document title is the single most-cited piece of text and should be 10\u201370 characters.",
1130
1191
  run(ctx) {
1131
1192
  const title = ctx.$("head > title").first().text().trim();
@@ -1134,6 +1195,7 @@ var titleRule = defineRule({
1134
1195
  status: "fail",
1135
1196
  score: 0,
1136
1197
  rationale: "Page has no <title>.",
1198
+ rationale_ko: "\uD398\uC774\uC9C0\uC5D0 <title>\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.",
1137
1199
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1138
1200
  };
1139
1201
  }
@@ -1141,17 +1203,19 @@ var titleRule = defineRule({
1141
1203
  return {
1142
1204
  status: "warn",
1143
1205
  score: 0.4,
1144
- rationale: `Title is only ${title.length} chars; consider a more descriptive one.`
1206
+ rationale: `Title is only ${title.length} chars; consider a more descriptive one.`,
1207
+ 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.`
1145
1208
  };
1146
1209
  }
1147
1210
  if (title.length > 70) {
1148
1211
  return {
1149
1212
  status: "warn",
1150
1213
  score: 0.6,
1151
- rationale: `Title is ${title.length} chars; search UIs commonly truncate after ~70.`
1214
+ rationale: `Title is ${title.length} chars; search UIs commonly truncate after ~70.`,
1215
+ 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.`
1152
1216
  };
1153
1217
  }
1154
- return { status: "pass", score: 1, rationale: `Title length ${title.length} is within range.` };
1218
+ 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.` };
1155
1219
  }
1156
1220
  });
1157
1221
 
@@ -1166,6 +1230,7 @@ var metaDescriptionRule = defineRule({
1166
1230
  effort: "low",
1167
1231
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citmeta-description",
1168
1232
  title: "meta description is set (50\u2013160 chars)",
1233
+ title_ko: "meta description \uC124\uC815 \uC5EC\uBD80 (50\u2013160\uC790)",
1169
1234
  description: "AI snippets often quote the meta description verbatim; aim for 50\u2013160 chars.",
1170
1235
  run(ctx) {
1171
1236
  const desc = ctx.$('head meta[name="description"]').attr("content")?.trim() ?? "";
@@ -1174,16 +1239,17 @@ var metaDescriptionRule = defineRule({
1174
1239
  status: "warn",
1175
1240
  score: 0,
1176
1241
  rationale: "No meta description set.",
1242
+ rationale_ko: "meta description\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.",
1177
1243
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1178
1244
  };
1179
1245
  }
1180
1246
  if (desc.length < 50) {
1181
- return { status: "warn", score: 0.5, rationale: `Only ${desc.length} chars; aim for 50+.` };
1247
+ 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.` };
1182
1248
  }
1183
1249
  if (desc.length > 160) {
1184
- return { status: "warn", score: 0.7, rationale: `${desc.length} chars; may be truncated after 160.` };
1250
+ 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.` };
1185
1251
  }
1186
- return { status: "pass", score: 1, rationale: `Description length ${desc.length} is within range.` };
1252
+ 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.` };
1187
1253
  }
1188
1254
  });
1189
1255
 
@@ -1198,6 +1264,7 @@ var canonicalRule = defineRule({
1198
1264
  effort: "low",
1199
1265
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citcanonical",
1200
1266
  title: "Canonical URL is declared",
1267
+ title_ko: "Canonical URL \uC120\uC5B8 \uC5EC\uBD80",
1201
1268
  description: 'rel="canonical" tells crawlers which URL is the source of truth, preventing duplicate-citation confusion.',
1202
1269
  run(ctx) {
1203
1270
  const href = ctx.$('head link[rel="canonical"]').attr("href")?.trim();
@@ -1206,14 +1273,15 @@ var canonicalRule = defineRule({
1206
1273
  status: "warn",
1207
1274
  score: 0,
1208
1275
  rationale: 'No <link rel="canonical"> found.',
1276
+ rationale_ko: '<link rel="canonical">\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.',
1209
1277
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1210
1278
  };
1211
1279
  }
1212
1280
  try {
1213
1281
  const abs = new URL(href, ctx.finalUrl).toString();
1214
- return { status: "pass", score: 1, rationale: `Canonical URL: ${abs}.` };
1282
+ return { status: "pass", score: 1, rationale: `Canonical URL: ${abs}.`, rationale_ko: `Canonical URL: ${abs}.` };
1215
1283
  } catch {
1216
- return { status: "fail", score: 0, rationale: `Canonical href is not a valid URL: ${href}` };
1284
+ 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}` };
1217
1285
  }
1218
1286
  }
1219
1287
  });
@@ -1230,6 +1298,7 @@ var ogTagsRule = defineRule({
1230
1298
  effort: "low",
1231
1299
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citog-tags",
1232
1300
  title: "Open Graph tags are set",
1301
+ title_ko: "Open Graph \uD0DC\uADF8 \uC124\uC815 \uC5EC\uBD80",
1233
1302
  description: "og:title/type/url/image power rich previews on AI chat, social, and messaging.",
1234
1303
  run(ctx) {
1235
1304
  const missing = [];
@@ -1238,13 +1307,14 @@ var ogTagsRule = defineRule({
1238
1307
  if (!val) missing.push(prop);
1239
1308
  }
1240
1309
  if (missing.length === 0) {
1241
- return { status: "pass", score: 1, rationale: "All required OG tags present." };
1310
+ 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." };
1242
1311
  }
1243
1312
  const ratio = 1 - missing.length / REQUIRED.length;
1244
1313
  return {
1245
1314
  status: missing.length === REQUIRED.length ? "fail" : "warn",
1246
1315
  score: ratio,
1247
1316
  rationale: `Missing: ${missing.join(", ")}.`,
1317
+ rationale_ko: `\uB204\uB77D\uB41C \uD0DC\uADF8: ${missing.join(", ")}.`,
1248
1318
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1249
1319
  };
1250
1320
  }
@@ -1261,20 +1331,22 @@ var twitterCardRule = defineRule({
1261
1331
  effort: "low",
1262
1332
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cittwitter-card",
1263
1333
  title: "Twitter Card metadata is set",
1334
+ title_ko: "Twitter Card \uBA54\uD0C0\uB370\uC774\uD130 \uC124\uC815 \uC5EC\uBD80",
1264
1335
  description: "twitter:card + twitter:title give better previews on X/Twitter and some AI surfaces that reuse the tags.",
1265
1336
  run(ctx) {
1266
1337
  const card = ctx.$('head meta[name="twitter:card"]').attr("content")?.trim();
1267
1338
  const title = ctx.$('head meta[name="twitter:title"]').attr("content")?.trim();
1268
1339
  if (card && title) {
1269
- return { status: "pass", score: 1, rationale: `Card type: ${card}.` };
1340
+ return { status: "pass", score: 1, rationale: `Card type: ${card}.`, rationale_ko: `\uCE74\uB4DC \uC720\uD615: ${card}.` };
1270
1341
  }
1271
1342
  if (card || title) {
1272
- return { status: "warn", score: 0.5, rationale: "Partial twitter:* metadata; add the missing tag." };
1343
+ 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." };
1273
1344
  }
1274
1345
  return {
1275
1346
  status: "warn",
1276
1347
  score: 0,
1277
1348
  rationale: "No twitter:card metadata.",
1349
+ rationale_ko: "twitter:card \uBA54\uD0C0\uB370\uC774\uD130\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.",
1278
1350
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1279
1351
  };
1280
1352
  }
@@ -1291,6 +1363,7 @@ var langAttrRule = defineRule({
1291
1363
  effort: "low",
1292
1364
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citlang-attr",
1293
1365
  title: "<html lang> is set",
1366
+ title_ko: "<html lang> \uC18D\uC131 \uC124\uC815 \uC5EC\uBD80",
1294
1367
  description: "A lang attribute helps AI engines route the page to the right-language search surface (and helps screen readers).",
1295
1368
  run(ctx) {
1296
1369
  const lang = ctx.$("html").attr("lang")?.trim();
@@ -1299,10 +1372,11 @@ var langAttrRule = defineRule({
1299
1372
  status: "warn",
1300
1373
  score: 0,
1301
1374
  rationale: "No lang attribute on <html>.",
1375
+ rationale_ko: "<html>\uC5D0 lang \uC18D\uC131\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.",
1302
1376
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1303
1377
  };
1304
1378
  }
1305
- return { status: "pass", score: 1, rationale: `lang="${lang}".` };
1379
+ return { status: "pass", score: 1, rationale: `lang="${lang}".`, rationale_ko: `lang="${lang}".` };
1306
1380
  }
1307
1381
  });
1308
1382
 
@@ -1317,25 +1391,27 @@ var authorVisibleRule = defineRule({
1317
1391
  effort: "medium",
1318
1392
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citauthor-visible",
1319
1393
  title: "Author is declared",
1394
+ title_ko: "\uC791\uC131\uC790 \uC815\uBCF4 \uC120\uC5B8 \uC5EC\uBD80",
1320
1395
  description: "AI engines prefer citing content with an identifiable author; expose one via JSON-LD, meta[name=author], rel=author, or a .author class.",
1321
1396
  run(ctx) {
1322
1397
  for (const node of flattenJsonLd(ctx.jsonLd)) {
1323
1398
  if (hasField(node, "author")) {
1324
- return { status: "pass", score: 1, rationale: "Author found in JSON-LD." };
1399
+ 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." };
1325
1400
  }
1326
1401
  }
1327
1402
  const metaAuthor = ctx.$('head meta[name="author"]').attr("content")?.trim();
1328
- if (metaAuthor) return { status: "pass", score: 1, rationale: `meta[name=author] = "${metaAuthor}".` };
1403
+ if (metaAuthor) return { status: "pass", score: 1, rationale: `meta[name=author] = "${metaAuthor}".`, rationale_ko: `meta[name=author] = "${metaAuthor}".` };
1329
1404
  if (ctx.$('[rel="author"]').length > 0) {
1330
- return { status: "pass", score: 1, rationale: 'rel="author" link found.' };
1405
+ return { status: "pass", score: 1, rationale: 'rel="author" link found.', rationale_ko: 'rel="author" \uB9C1\uD06C\uB97C \uCC3E\uC558\uC2B5\uB2C8\uB2E4.' };
1331
1406
  }
1332
1407
  if (ctx.$('.author, [class*="author"], [itemprop="author"]').length > 0) {
1333
- return { status: "pass", score: 0.8, rationale: "Author-ish DOM selector found (weaker signal)." };
1408
+ 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)." };
1334
1409
  }
1335
1410
  return {
1336
1411
  status: "warn",
1337
1412
  score: 0,
1338
1413
  rationale: "No author signal found (JSON-LD, meta, rel, or .author).",
1414
+ rationale_ko: "\uC791\uC131\uC790 \uC815\uBCF4\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4 (JSON-LD, meta, rel, .author \uBAA8\uB450 \uC5C6\uC74C).",
1339
1415
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1340
1416
  };
1341
1417
  }
@@ -1352,25 +1428,27 @@ var datesRule = defineRule({
1352
1428
  effort: "low",
1353
1429
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citdates",
1354
1430
  title: "Publish / modified date is present",
1431
+ title_ko: "\uBC1C\uD589\uC77C / \uC218\uC815\uC77C \uC874\uC7AC \uC5EC\uBD80",
1355
1432
  description: "AI engines rank recent pages higher; expose datePublished via JSON-LD, <time datetime>, or article:published_time meta.",
1356
1433
  run(ctx) {
1357
1434
  for (const node of flattenJsonLd(ctx.jsonLd)) {
1358
1435
  if (hasField(node, "datePublished")) {
1359
- return { status: "pass", score: 1, rationale: "datePublished found in JSON-LD." };
1436
+ return { status: "pass", score: 1, rationale: "datePublished found in JSON-LD.", rationale_ko: "JSON-LD\uC5D0\uC11C datePublished\uB97C \uCC3E\uC558\uC2B5\uB2C8\uB2E4." };
1360
1437
  }
1361
1438
  }
1362
1439
  const articleTime = ctx.$('head meta[property="article:published_time"]').attr("content")?.trim();
1363
1440
  if (articleTime) {
1364
- return { status: "pass", score: 1, rationale: `article:published_time = ${articleTime}.` };
1441
+ return { status: "pass", score: 1, rationale: `article:published_time = ${articleTime}.`, rationale_ko: `article:published_time = ${articleTime}.` };
1365
1442
  }
1366
1443
  const timeEl = ctx.$("time[datetime]").first().attr("datetime")?.trim();
1367
1444
  if (timeEl) {
1368
- return { status: "pass", score: 0.8, rationale: `<time datetime="${timeEl}"> found.` };
1445
+ return { status: "pass", score: 0.8, rationale: `<time datetime="${timeEl}"> found.`, rationale_ko: `<time datetime="${timeEl}">\uB97C \uCC3E\uC558\uC2B5\uB2C8\uB2E4.` };
1369
1446
  }
1370
1447
  return {
1371
1448
  status: "warn",
1372
1449
  score: 0,
1373
1450
  rationale: "No publish date found (JSON-LD, meta article:published_time, or <time datetime>).",
1451
+ rationale_ko: "\uBC1C\uD589\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4 (JSON-LD, meta article:published_time, <time datetime> \uBAA8\uB450 \uC5C6\uC74C).",
1374
1452
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1375
1453
  };
1376
1454
  }
@@ -1398,6 +1476,7 @@ var contentFreshnessRule = defineRule({
1398
1476
  effort: "low",
1399
1477
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#citcontent-freshness",
1400
1478
  title: "Article content is fresh (dateModified within 1 year)",
1479
+ title_ko: "\uCF58\uD150\uCE20 \uCD5C\uC2E0\uC131 (dateModified 1\uB144 \uC774\uB0B4)",
1401
1480
  description: "AI engines down-rank stale content. Surface a recent dateModified (\u2264365 days) on Article-like pages so retrieval rankings stay strong.",
1402
1481
  run(ctx) {
1403
1482
  const nodes = flattenJsonLd(ctx.jsonLd);
@@ -1406,7 +1485,8 @@ var contentFreshnessRule = defineRule({
1406
1485
  return {
1407
1486
  status: "skip",
1408
1487
  score: 0,
1409
- rationale: "No Article/BlogPosting/NewsArticle JSON-LD; freshness signal not applicable."
1488
+ rationale: "No Article/BlogPosting/NewsArticle JSON-LD; freshness signal not applicable.",
1489
+ rationale_ko: "Article/BlogPosting/NewsArticle JSON-LD\uAC00 \uC5C6\uC5B4 \uCD5C\uC2E0\uC131 \uC2E0\uD638\uB97C \uD655\uC778\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4."
1410
1490
  };
1411
1491
  }
1412
1492
  let bestMs = null;
@@ -1428,6 +1508,7 @@ var contentFreshnessRule = defineRule({
1428
1508
  status: "warn",
1429
1509
  score: 0,
1430
1510
  rationale: "Article has no parseable dateModified or datePublished.",
1511
+ rationale_ko: "Article JSON-LD\uC5D0 \uD30C\uC2F1 \uAC00\uB2A5\uD55C dateModified \uB610\uB294 datePublished\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.",
1431
1512
  fixHint: "Add ISO-8601 dateModified and datePublished to your Article JSON-LD.",
1432
1513
  estimatedImpact: 3
1433
1514
  };
@@ -1438,6 +1519,7 @@ var contentFreshnessRule = defineRule({
1438
1519
  status: "pass",
1439
1520
  score: 1,
1440
1521
  rationale: `${usedField} within the last year (~${ageDays} day${ageDays === 1 ? "" : "s"} ago).`,
1522
+ rationale_ko: `${usedField}\uC774 1\uB144 \uC774\uB0B4\uC785\uB2C8\uB2E4 (\uC57D ${ageDays}\uC77C \uC804).`,
1441
1523
  evidence: { ageDays, field: usedField }
1442
1524
  };
1443
1525
  }
@@ -1446,6 +1528,7 @@ var contentFreshnessRule = defineRule({
1446
1528
  status: "warn",
1447
1529
  score: 0.6,
1448
1530
  rationale: `${usedField} is ${ageDays} days old. Refresh within a year for best AI ranking.`,
1531
+ 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.`,
1449
1532
  evidence: { ageDays, field: usedField },
1450
1533
  estimatedImpact: 2
1451
1534
  };
@@ -1454,6 +1537,7 @@ var contentFreshnessRule = defineRule({
1454
1537
  status: "warn",
1455
1538
  score: 0.2,
1456
1539
  rationale: `${usedField} is ${ageDays} days old (>2 years). AI engines treat this as stale.`,
1540
+ 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.`,
1457
1541
  evidence: { ageDays, field: usedField },
1458
1542
  fixHint: "Update content and bump dateModified to today's date.",
1459
1543
  estimatedImpact: 3
@@ -1485,22 +1569,25 @@ var singleH1Rule = defineRule({
1485
1569
  effort: "low",
1486
1570
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntsingle-h1",
1487
1571
  title: "Exactly one <h1>",
1572
+ title_ko: "<h1> \uD0DC\uADF8 1\uAC1C \uC5EC\uBD80",
1488
1573
  description: "A single H1 tells AI engines the primary topic of the page without ambiguity.",
1489
1574
  run(ctx) {
1490
1575
  const n = ctx.$("h1").length;
1491
- if (n === 1) return { status: "pass", score: 1, rationale: "Exactly one <h1>." };
1576
+ if (n === 1) return { status: "pass", score: 1, rationale: "Exactly one <h1>.", rationale_ko: "<h1>\uC774 \uC815\uD655\uD788 1\uAC1C\uC785\uB2C8\uB2E4." };
1492
1577
  if (n === 0) {
1493
1578
  return {
1494
1579
  status: "fail",
1495
1580
  score: 0,
1496
1581
  rationale: "No <h1> on the page.",
1582
+ rationale_ko: "\uD398\uC774\uC9C0\uC5D0 <h1>\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.",
1497
1583
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules/cnt.single-h1.md"
1498
1584
  };
1499
1585
  }
1500
1586
  return {
1501
1587
  status: "warn",
1502
1588
  score: Math.max(0.3, 1 / n),
1503
- rationale: `Found ${n} <h1> tags; prefer one primary heading.`
1589
+ rationale: `Found ${n} <h1> tags; prefer one primary heading.`,
1590
+ rationale_ko: `<h1>\uC774 ${n}\uAC1C \uC788\uC2B5\uB2C8\uB2E4. \uB300\uD45C \uC81C\uBAA9 1\uAC1C\uB9CC \uC0AC\uC6A9\uD558\uC138\uC694.`
1504
1591
  };
1505
1592
  }
1506
1593
  });
@@ -1516,6 +1603,7 @@ var headingHierarchyRule = defineRule({
1516
1603
  effort: "medium",
1517
1604
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntheading-hierarchy",
1518
1605
  title: "Heading levels do not skip",
1606
+ title_ko: "\uC81C\uBAA9 \uB2E8\uACC4 \uC21C\uC11C \uC900\uC218 \uC5EC\uBD80",
1519
1607
  description: "Going from H2 directly to H4 breaks the outline AI engines use to segment content.",
1520
1608
  run(ctx) {
1521
1609
  const levels = [];
@@ -1525,7 +1613,7 @@ var headingHierarchyRule = defineRule({
1525
1613
  if (m?.[1]) levels.push(parseInt(m[1], 10));
1526
1614
  });
1527
1615
  if (levels.length === 0) {
1528
- return { status: "skip", score: 0, rationale: "No headings found." };
1616
+ return { status: "skip", score: 0, rationale: "No headings found.", rationale_ko: "\uC81C\uBAA9 \uD0DC\uADF8\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." };
1529
1617
  }
1530
1618
  const skips = [];
1531
1619
  for (let i = 1; i < levels.length; i++) {
@@ -1534,12 +1622,13 @@ var headingHierarchyRule = defineRule({
1534
1622
  if (curr > prev + 1) skips.push({ from: prev, to: curr });
1535
1623
  }
1536
1624
  if (skips.length === 0) {
1537
- return { status: "pass", score: 1, rationale: "No heading-level skips." };
1625
+ 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." };
1538
1626
  }
1539
1627
  return {
1540
1628
  status: "warn",
1541
1629
  score: Math.max(0.3, 1 - skips.length / levels.length),
1542
1630
  rationale: `${skips.length} heading skip(s) detected (e.g. h${skips[0].from}\u2192h${skips[0].to}).`,
1631
+ 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}).`,
1543
1632
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1544
1633
  };
1545
1634
  }
@@ -1556,11 +1645,12 @@ var imageAltRule = defineRule({
1556
1645
  effort: "medium",
1557
1646
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntimage-alt",
1558
1647
  title: "\u226580% of <img> have alt text",
1648
+ title_ko: "<img>\uC758 80% \uC774\uC0C1 alt \uD14D\uC2A4\uD2B8 \uBCF4\uC720 \uC5EC\uBD80",
1559
1649
  description: "Alt text gives AI engines a textual anchor for visual content and improves accessibility.",
1560
1650
  run(ctx) {
1561
1651
  const imgs = ctx.$("img");
1562
1652
  const total = imgs.length;
1563
- if (total === 0) return { status: "skip", score: 0, rationale: "No <img> on the page." };
1653
+ 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." };
1564
1654
  let withAlt = 0;
1565
1655
  imgs.each((_i, el) => {
1566
1656
  const alt = ctx.$(el).attr("alt");
@@ -1568,12 +1658,13 @@ var imageAltRule = defineRule({
1568
1658
  });
1569
1659
  const ratio = withAlt / total;
1570
1660
  if (ratio >= 0.8) {
1571
- return { status: "pass", score: 1, rationale: `${withAlt}/${total} images have alt (${Math.round(ratio * 100)}%).` };
1661
+ 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)}%).` };
1572
1662
  }
1573
1663
  return {
1574
1664
  status: "warn",
1575
1665
  score: ratio,
1576
1666
  rationale: `Only ${withAlt}/${total} images have alt text (${Math.round(ratio * 100)}%). Aim for \u226580%.`,
1667
+ 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.`,
1577
1668
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1578
1669
  };
1579
1670
  }
@@ -1590,11 +1681,12 @@ var tldrOrFaqRule = defineRule({
1590
1681
  effort: "medium",
1591
1682
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cnttldr-or-faq",
1592
1683
  title: "TL;DR summary or FAQ block",
1684
+ title_ko: "TL;DR \uC694\uC57D \uB610\uB294 FAQ \uBE14\uB85D \uC874\uC7AC \uC5EC\uBD80",
1593
1685
  description: 'AI engines strongly prefer content with a quotable summary or FAQ \u2014 it makes the page "citation-ready".',
1594
1686
  run(ctx) {
1595
1687
  for (const node of flattenJsonLd(ctx.jsonLd)) {
1596
1688
  if (getTypes(node).includes("FAQPage")) {
1597
- return { status: "pass", score: 1, rationale: "FAQPage schema present." };
1689
+ return { status: "pass", score: 1, rationale: "FAQPage schema present.", rationale_ko: "FAQPage \uC2A4\uD0A4\uB9C8\uAC00 \uC788\uC2B5\uB2C8\uB2E4." };
1598
1690
  }
1599
1691
  }
1600
1692
  const sel = [
@@ -1607,12 +1699,13 @@ var tldrOrFaqRule = defineRule({
1607
1699
  "[data-tldr]"
1608
1700
  ].join(", ");
1609
1701
  if (ctx.$(sel).length > 0) {
1610
- return { status: "pass", score: 0.85, rationale: "TL;DR / summary / FAQ region detected by selector." };
1702
+ 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." };
1611
1703
  }
1612
1704
  return {
1613
1705
  status: "warn",
1614
1706
  score: 0,
1615
1707
  rationale: "No TL;DR / summary / FAQ found; add one to boost AI citation odds.",
1708
+ 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.",
1616
1709
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1617
1710
  };
1618
1711
  }
@@ -1629,6 +1722,7 @@ var wordCountRule = defineRule({
1629
1722
  effort: "high",
1630
1723
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntword-count",
1631
1724
  title: "Page has enough body text",
1725
+ title_ko: "\uCDA9\uBD84\uD55C \uBCF8\uBB38 \uD14D\uC2A4\uD2B8 \uC5EC\uBD80",
1632
1726
  description: "Thin pages (under ~100 words) are rarely cited by AI engines. Aim for \u2265300 words of meaningful body copy.",
1633
1727
  run(ctx) {
1634
1728
  const $ = ctx.$;
@@ -1636,12 +1730,13 @@ var wordCountRule = defineRule({
1636
1730
  clone.find("script, style, noscript, nav, header, footer, aside").remove();
1637
1731
  const text = clone.text().replace(/\s+/g, " ").trim();
1638
1732
  const words = text ? text.split(" ").length : 0;
1639
- if (words >= 300) return { status: "pass", score: 1, rationale: `${words} words of body text.` };
1640
- if (words >= 100) return { status: "warn", score: 0.5, rationale: `Only ${words} words; aim for 300+.` };
1733
+ 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.` };
1734
+ 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.` };
1641
1735
  return {
1642
1736
  status: "fail",
1643
1737
  score: 0,
1644
1738
  rationale: `Only ${words} words; too thin to be cited.`,
1739
+ 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.`,
1645
1740
  fixUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md"
1646
1741
  };
1647
1742
  }
@@ -1692,11 +1787,12 @@ var qaStructureRule = defineRule({
1692
1787
  effort: "medium",
1693
1788
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntqa-structure",
1694
1789
  title: "Content uses Q&A structure for answer extraction",
1790
+ title_ko: "\uB2F5\uBCC0 \uCD94\uCD9C\uC744 \uC704\uD55C Q&A \uAD6C\uC870 \uC0AC\uC6A9 \uC5EC\uBD80",
1695
1791
  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.",
1696
1792
  run(ctx) {
1697
1793
  for (const node of flattenJsonLd(ctx.jsonLd)) {
1698
1794
  if (getTypes(node).includes("FAQPage")) {
1699
- return { status: "pass", score: 1, rationale: "FAQPage JSON-LD provides explicit Q&A." };
1795
+ 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." };
1700
1796
  }
1701
1797
  }
1702
1798
  const questionHeadings = [];
@@ -1709,6 +1805,7 @@ var qaStructureRule = defineRule({
1709
1805
  status: "pass",
1710
1806
  score: 1,
1711
1807
  rationale: `${questionHeadings.length} question-style headings detected.`,
1808
+ rationale_ko: `\uC9C8\uBB38\uD615 \uC81C\uBAA9\uC774 ${questionHeadings.length}\uAC1C \uAC10\uC9C0\uB429\uB2C8\uB2E4.`,
1712
1809
  evidence: { headings: questionHeadings.slice(0, 5) }
1713
1810
  };
1714
1811
  }
@@ -1717,6 +1814,7 @@ var qaStructureRule = defineRule({
1717
1814
  status: "warn",
1718
1815
  score: 0.6,
1719
1816
  rationale: "1 question-style heading. Add a second to strengthen answer extraction.",
1817
+ 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.",
1720
1818
  evidence: { headings: questionHeadings },
1721
1819
  estimatedImpact: 1
1722
1820
  };
@@ -1725,6 +1823,7 @@ var qaStructureRule = defineRule({
1725
1823
  status: "warn",
1726
1824
  score: 0,
1727
1825
  rationale: "No question-style H2/H3 headings or FAQPage JSON-LD found.",
1826
+ rationale_ko: "\uC9C8\uBB38\uD615 H2/H3 \uC81C\uBAA9\uC774\uB098 FAQPage JSON-LD\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.",
1728
1827
  fixHint: 'Reframe at least 2 H2 headings as questions ("How do I\u2026?", "What is\u2026?") or add FAQPage JSON-LD.',
1729
1828
  estimatedImpact: 3
1730
1829
  };
@@ -1742,13 +1841,14 @@ var externalCitationsRule = defineRule({
1742
1841
  effort: "medium",
1743
1842
  docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#cntexternal-citations",
1744
1843
  title: "Content cites external sources",
1844
+ title_ko: "\uC678\uBD80 \uCD9C\uCC98 \uC778\uC6A9 \uC5EC\uBD80",
1745
1845
  description: "Outbound links to authoritative external sources are an E-E-A-T trust signal. AI engines treat well-cited pages as more credible.",
1746
1846
  run(ctx) {
1747
1847
  let host;
1748
1848
  try {
1749
1849
  host = new URL(ctx.finalUrl).hostname.toLowerCase();
1750
1850
  } catch {
1751
- return { status: "skip", score: 0, rationale: "Invalid finalUrl." };
1851
+ return { status: "skip", score: 0, rationale: "Invalid finalUrl.", rationale_ko: "\uC720\uD6A8\uD558\uC9C0 \uC54A\uC740 finalUrl\uC785\uB2C8\uB2E4." };
1752
1852
  }
1753
1853
  const seen = /* @__PURE__ */ new Set();
1754
1854
  ctx.$("main a[href], article a[href], body a[href]").each((_i, el) => {
@@ -1773,6 +1873,7 @@ var externalCitationsRule = defineRule({
1773
1873
  status: "pass",
1774
1874
  score: 1,
1775
1875
  rationale: `${count} distinct external host(s) cited (excluding nofollow).`,
1876
+ rationale_ko: `\uC678\uBD80 \uC0AC\uC774\uD2B8 ${count}\uAC1C\uB97C \uC778\uC6A9\uD569\uB2C8\uB2E4 (nofollow \uC81C\uC678).`,
1776
1877
  evidence: { hosts: [...seen].slice(0, 8) }
1777
1878
  };
1778
1879
  }
@@ -1781,6 +1882,7 @@ var externalCitationsRule = defineRule({
1781
1882
  status: "pass",
1782
1883
  score: 0.7,
1783
1884
  rationale: `${count} external host(s) cited. Aim for \u22653 for stronger E-E-A-T.`,
1885
+ 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.`,
1784
1886
  evidence: { hosts: [...seen] },
1785
1887
  estimatedImpact: 1
1786
1888
  };
@@ -1789,6 +1891,7 @@ var externalCitationsRule = defineRule({
1789
1891
  status: "warn",
1790
1892
  score: 0,
1791
1893
  rationale: "No external follow citations found in main content.",
1894
+ rationale_ko: "\uBCF8\uBB38\uC5D0 \uC678\uBD80 \uCD9C\uCC98 \uB9C1\uD06C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.",
1792
1895
  fixHint: "Cite at least one authoritative external source (research paper, official docs, news outlet).",
1793
1896
  estimatedImpact: 2
1794
1897
  };
@@ -1806,12 +1909,130 @@ var contentRules = [
1806
1909
  externalCitationsRule
1807
1910
  ];
1808
1911
 
1912
+ // src/rules/aeo/skill-md.ts
1913
+ var aeoSkillMdRule = defineRule({
1914
+ id: "aeo.skill-md",
1915
+ stableId: "aeo.skill-md",
1916
+ category: "aeo",
1917
+ group: "opportunity",
1918
+ weight: 3,
1919
+ impact: "high",
1920
+ effort: "low",
1921
+ docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#aeoskill-md",
1922
+ title: "skill.md is present",
1923
+ title_ko: "skill.md \uD30C\uC77C \uC874\uC7AC \uC5EC\uBD80",
1924
+ description: "A /skill.md file describes site capabilities so AI agents know what this site can do for them.",
1925
+ run(ctx) {
1926
+ if (ctx.skillMd !== null) {
1927
+ return {
1928
+ status: "pass",
1929
+ score: 1,
1930
+ rationale: "skill.md found at site root.",
1931
+ rationale_ko: "skill.md\uAC00 \uC0AC\uC774\uD2B8 \uB8E8\uD2B8\uC5D0 \uC874\uC7AC\uD569\uB2C8\uB2E4."
1932
+ };
1933
+ }
1934
+ return {
1935
+ status: "warn",
1936
+ score: 0,
1937
+ rationale: "No /skill.md found. Add one to describe your site capabilities to AI agents.",
1938
+ 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.",
1939
+ fixHint: "Create /skill.md listing what services, products, and capabilities this site offers."
1940
+ };
1941
+ }
1942
+ });
1943
+
1944
+ // src/rules/aeo/agent-permissions.ts
1945
+ var aeoAgentPermissionsRule = defineRule({
1946
+ id: "aeo.agent-permissions",
1947
+ stableId: "aeo.agent-permissions",
1948
+ category: "aeo",
1949
+ group: "opportunity",
1950
+ weight: 3,
1951
+ impact: "medium",
1952
+ effort: "low",
1953
+ docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#aeoagent-permissions",
1954
+ title: "agent-permissions.json is present",
1955
+ title_ko: "agent-permissions.json \uD30C\uC77C \uC874\uC7AC \uC5EC\uBD80",
1956
+ description: "Declares explicit read/summarize/cite/train permissions for AI agents.",
1957
+ run(ctx) {
1958
+ if (ctx.agentPermissions !== null) {
1959
+ return {
1960
+ status: "pass",
1961
+ score: 1,
1962
+ rationale: "agent-permissions.json found at site root.",
1963
+ rationale_ko: "agent-permissions.json\uC774 \uC0AC\uC774\uD2B8 \uB8E8\uD2B8\uC5D0 \uC874\uC7AC\uD569\uB2C8\uB2E4.",
1964
+ evidence: ctx.agentPermissions
1965
+ };
1966
+ }
1967
+ return {
1968
+ status: "warn",
1969
+ score: 0,
1970
+ rationale: "No /agent-permissions.json found. Add one to declare AI agent access policy.",
1971
+ 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.",
1972
+ fixHint: "Create /agent-permissions.json with read, summarize, cite, and train permission flags."
1973
+ };
1974
+ }
1975
+ });
1976
+
1977
+ // src/rules/aeo/token-length.ts
1978
+ var THRESHOLD_OPTIMAL = 15e3;
1979
+ var THRESHOLD_MAX = 25e3;
1980
+ var aeoTokenLengthRule = defineRule({
1981
+ id: "aeo.token-length",
1982
+ stableId: "aeo.token-length",
1983
+ category: "aeo",
1984
+ group: "diagnostic",
1985
+ weight: 4,
1986
+ impact: "medium",
1987
+ effort: "medium",
1988
+ docsUrl: "https://github.com/BaRam-OSS/geo-checker/blob/main/docs/rules.md#aeotoken-length",
1989
+ title: "Content token length within AI agent limits",
1990
+ title_ko: "\uCF58\uD150\uCE20 \uD1A0\uD070 \uC218 AI \uC5D0\uC774\uC804\uD2B8 \uAD8C\uC7A5 \uBC94\uC704",
1991
+ description: "Pages under 15K tokens are optimal for AI agents (per Addy Osmani's AEO guidance).",
1992
+ run(ctx) {
1993
+ const text = ctx.$("body").text();
1994
+ const tokenEstimate = Math.round(text.length / 3);
1995
+ const evidence = { tokenEstimate, thresholds: { optimal: THRESHOLD_OPTIMAL, max: THRESHOLD_MAX } };
1996
+ if (tokenEstimate <= THRESHOLD_OPTIMAL) {
1997
+ return {
1998
+ status: "pass",
1999
+ score: 1,
2000
+ rationale: `Estimated ~${tokenEstimate.toLocaleString()} tokens \u2014 within optimal range.`,
2001
+ rationale_ko: `\uC608\uC0C1 \uD1A0\uD070 \uC218 ~${tokenEstimate.toLocaleString()} \u2014 \uAD8C\uC7A5 \uBC94\uC704(15K) \uC774\uB0B4\uC785\uB2C8\uB2E4.`,
2002
+ evidence
2003
+ };
2004
+ }
2005
+ if (tokenEstimate <= THRESHOLD_MAX) {
2006
+ return {
2007
+ status: "warn",
2008
+ score: 0.5,
2009
+ rationale: `Estimated ~${tokenEstimate.toLocaleString()} tokens \u2014 exceeds 15K recommendation.`,
2010
+ rationale_ko: `\uC608\uC0C1 \uD1A0\uD070 \uC218 ~${tokenEstimate.toLocaleString()} \u2014 15K \uAD8C\uC7A5\uCE58\uB97C \uCD08\uACFC\uD569\uB2C8\uB2E4.`,
2011
+ fixHint: "Consider splitting content into shorter, focused pages.",
2012
+ evidence
2013
+ };
2014
+ }
2015
+ return {
2016
+ status: "fail",
2017
+ score: 0,
2018
+ rationale: `Estimated ~${tokenEstimate.toLocaleString()} tokens \u2014 exceeds 25K agent processing limit.`,
2019
+ 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.`,
2020
+ fixHint: "Split this page into multiple focused pages under 15K tokens.",
2021
+ evidence
2022
+ };
2023
+ }
2024
+ });
2025
+
2026
+ // src/rules/aeo/index.ts
2027
+ var aeoRules = [aeoSkillMdRule, aeoAgentPermissionsRule, aeoTokenLengthRule];
2028
+
1809
2029
  // src/rules/index.ts
1810
2030
  var defaultRules = [
1811
2031
  ...crawlerRules,
1812
2032
  ...structuredDataRules,
1813
2033
  ...citationRules,
1814
- ...contentRules
2034
+ ...contentRules,
2035
+ ...aeoRules
1815
2036
  ];
1816
2037
 
1817
2038
  // src/config.ts
@@ -2007,7 +2228,8 @@ var CATEGORY_LABELS = {
2007
2228
  crawler: "AI Crawler Access",
2008
2229
  "structured-data": "Structured Data",
2009
2230
  citation: "Citation Signals",
2010
- content: "Content Structure"
2231
+ content: "Content Structure",
2232
+ aeo: "AEO Stack"
2011
2233
  };
2012
2234
  function scoreBadge(score) {
2013
2235
  const color = score >= 85 ? "brightgreen" : score >= 60 ? "yellow" : "red";