pabal-web-mcp 1.4.6 → 1.4.7

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.
Files changed (2) hide show
  1. package/dist/bin/mcp-server.js +471 -190
  2. package/package.json +1 -1
@@ -352,6 +352,209 @@ import { z as z2 } from "zod";
352
352
  import { zodToJsonSchema as zodToJsonSchema2 } from "zod-to-json-schema";
353
353
  import path4 from "path";
354
354
 
355
+ // src/utils/aso-validation.util.ts
356
+ var FIELD_LIMITS_DOC_PATH = "docs/aso/ASO_FIELD_LIMITS.md";
357
+ var APP_STORE_LIMITS = {
358
+ name: 30,
359
+ subtitle: 30,
360
+ keywords: 100,
361
+ promotionalText: 170,
362
+ description: 4e3,
363
+ whatsNew: 4e3
364
+ };
365
+ var GOOGLE_PLAY_LIMITS = {
366
+ title: 50,
367
+ shortDescription: 80,
368
+ fullDescription: 4e3,
369
+ releaseNotes: 500
370
+ };
371
+ var INVALID_CHAR_REGEX = /[\u0000-\u0008\u000B-\u000C\u000E-\u001F\u007F-\u009F\uFEFF\u200B-\u200F\u202A-\u202E\u2060\uFE00-\uFE0F]/g;
372
+ function sanitizeText(value, fieldPath, warnings) {
373
+ if (typeof value !== "string") return value;
374
+ const cleaned = value.replace(INVALID_CHAR_REGEX, "");
375
+ if (cleaned !== value) {
376
+ warnings.push(`Removed invalid characters from ${fieldPath}`);
377
+ }
378
+ return cleaned;
379
+ }
380
+ function sanitizeAsoData(configData) {
381
+ const sanitizedData = JSON.parse(JSON.stringify(configData));
382
+ const warnings = [];
383
+ if (sanitizedData.appStore) {
384
+ const appStoreData = sanitizedData.appStore;
385
+ const locales = isAppStoreMultilingual(appStoreData) ? appStoreData.locales : { [appStoreData.locale || DEFAULT_LOCALE]: appStoreData };
386
+ for (const [locale, data] of Object.entries(locales)) {
387
+ data.name = sanitizeText(
388
+ data.name,
389
+ `App Store [${locale}].name`,
390
+ warnings
391
+ );
392
+ data.subtitle = sanitizeText(
393
+ data.subtitle,
394
+ `App Store [${locale}].subtitle`,
395
+ warnings
396
+ );
397
+ data.keywords = sanitizeText(
398
+ data.keywords,
399
+ `App Store [${locale}].keywords`,
400
+ warnings
401
+ );
402
+ data.promotionalText = sanitizeText(
403
+ data.promotionalText,
404
+ `App Store [${locale}].promotionalText`,
405
+ warnings
406
+ );
407
+ data.description = sanitizeText(
408
+ data.description,
409
+ `App Store [${locale}].description`,
410
+ warnings
411
+ );
412
+ data.whatsNew = sanitizeText(
413
+ data.whatsNew,
414
+ `App Store [${locale}].whatsNew`,
415
+ warnings
416
+ );
417
+ }
418
+ }
419
+ if (sanitizedData.googlePlay) {
420
+ const googlePlayData = sanitizedData.googlePlay;
421
+ const locales = isGooglePlayMultilingual(googlePlayData) ? googlePlayData.locales : {
422
+ [googlePlayData.defaultLanguage || DEFAULT_LOCALE]: googlePlayData
423
+ };
424
+ for (const [locale, data] of Object.entries(locales)) {
425
+ data.title = sanitizeText(
426
+ data.title,
427
+ `Google Play [${locale}].title`,
428
+ warnings
429
+ );
430
+ data.shortDescription = sanitizeText(
431
+ data.shortDescription,
432
+ `Google Play [${locale}].shortDescription`,
433
+ warnings
434
+ );
435
+ data.fullDescription = sanitizeText(
436
+ data.fullDescription,
437
+ `Google Play [${locale}].fullDescription`,
438
+ warnings
439
+ );
440
+ }
441
+ }
442
+ return { sanitizedData, warnings };
443
+ }
444
+ function validateFieldLimits(configData) {
445
+ const issues = [];
446
+ if (configData.appStore) {
447
+ const appStoreData = configData.appStore;
448
+ const locales = isAppStoreMultilingual(appStoreData) ? appStoreData.locales : { [appStoreData.locale || DEFAULT_LOCALE]: appStoreData };
449
+ for (const [locale, data] of Object.entries(locales)) {
450
+ const checkField = (field, value) => {
451
+ if (typeof value === "string" && value.length > APP_STORE_LIMITS[field]) {
452
+ issues.push({
453
+ locale,
454
+ store: "appStore",
455
+ field,
456
+ currentLength: value.length,
457
+ limit: APP_STORE_LIMITS[field],
458
+ severity: "error"
459
+ });
460
+ }
461
+ };
462
+ checkField("name", data.name);
463
+ checkField("subtitle", data.subtitle);
464
+ checkField("keywords", data.keywords);
465
+ checkField("promotionalText", data.promotionalText);
466
+ checkField("description", data.description);
467
+ checkField("whatsNew", data.whatsNew);
468
+ }
469
+ }
470
+ if (configData.googlePlay) {
471
+ const googlePlayData = configData.googlePlay;
472
+ const locales = isGooglePlayMultilingual(googlePlayData) ? googlePlayData.locales : {
473
+ [googlePlayData.defaultLanguage || DEFAULT_LOCALE]: googlePlayData
474
+ };
475
+ for (const [locale, data] of Object.entries(locales)) {
476
+ const checkField = (field, value) => {
477
+ if (typeof value === "string" && value.length > GOOGLE_PLAY_LIMITS[field]) {
478
+ issues.push({
479
+ locale,
480
+ store: "googlePlay",
481
+ field,
482
+ currentLength: value.length,
483
+ limit: GOOGLE_PLAY_LIMITS[field],
484
+ severity: "error"
485
+ });
486
+ }
487
+ };
488
+ checkField("title", data.title);
489
+ checkField("shortDescription", data.shortDescription);
490
+ checkField("fullDescription", data.fullDescription);
491
+ }
492
+ }
493
+ return issues;
494
+ }
495
+ function formatValidationIssues(issues) {
496
+ if (issues.length === 0) {
497
+ return `\u2705 All fields within limits (checked against ${FIELD_LIMITS_DOC_PATH})`;
498
+ }
499
+ const grouped = {};
500
+ for (const issue of issues) {
501
+ const key = `${issue.store} [${issue.locale}]`;
502
+ if (!grouped[key]) grouped[key] = [];
503
+ grouped[key].push(issue);
504
+ }
505
+ const lines = [
506
+ `\u26A0\uFE0F Field limit violations (see ${FIELD_LIMITS_DOC_PATH}):`
507
+ ];
508
+ for (const [key, localeIssues] of Object.entries(grouped)) {
509
+ lines.push(`
510
+ ${key}:`);
511
+ for (const issue of localeIssues) {
512
+ const over = issue.currentLength - issue.limit;
513
+ lines.push(
514
+ ` - ${issue.field}: ${issue.currentLength}/${issue.limit} (${over} over)`
515
+ );
516
+ }
517
+ }
518
+ return lines.join("\n");
519
+ }
520
+ function checkKeywordDuplicates(keywords) {
521
+ const keywordList = keywords.split(",").map((k) => k.trim().toLowerCase()).filter(Boolean);
522
+ const seen = /* @__PURE__ */ new Set();
523
+ const duplicates = [];
524
+ const uniqueKeywords = [];
525
+ for (const keyword of keywordList) {
526
+ if (seen.has(keyword)) {
527
+ if (!duplicates.includes(keyword)) {
528
+ duplicates.push(keyword);
529
+ }
530
+ } else {
531
+ seen.add(keyword);
532
+ uniqueKeywords.push(keyword);
533
+ }
534
+ }
535
+ return {
536
+ hasDuplicates: duplicates.length > 0,
537
+ duplicates,
538
+ uniqueKeywords
539
+ };
540
+ }
541
+ function validateKeywords(configData) {
542
+ const issues = [];
543
+ if (configData.appStore) {
544
+ const appStoreData = configData.appStore;
545
+ const locales = isAppStoreMultilingual(appStoreData) ? appStoreData.locales : { [appStoreData.locale || DEFAULT_LOCALE]: appStoreData };
546
+ for (const [locale, data] of Object.entries(locales)) {
547
+ if (data.keywords) {
548
+ const result = checkKeywordDuplicates(data.keywords);
549
+ if (result.hasDuplicates) {
550
+ issues.push({ locale, duplicates: result.duplicates });
551
+ }
552
+ }
553
+ }
554
+ }
555
+ return issues;
556
+ }
557
+
355
558
  // src/tools/utils/public-to-aso/prepare-aso-data-for-push.util.ts
356
559
  function prepareAsoDataForPush(slug, configData) {
357
560
  const storeData = {};
@@ -516,21 +719,6 @@ function convertToMultilingual(data, locale) {
516
719
 
517
720
  // src/tools/public-to-aso.ts
518
721
  import fs4 from "fs";
519
- var FIELD_LIMITS_DOC_PATH = "docs/aso/ASO_FIELD_LIMITS.md";
520
- var APP_STORE_LIMITS = {
521
- name: 30,
522
- subtitle: 30,
523
- keywords: 100,
524
- promotionalText: 170,
525
- description: 4e3,
526
- whatsNew: 4e3
527
- };
528
- var GOOGLE_PLAY_LIMITS = {
529
- title: 50,
530
- shortDescription: 80,
531
- fullDescription: 4e3
532
- };
533
- var INVALID_CHAR_REGEX = /[\u0000-\u0008\u000B-\u000C\u000E-\u001F\u007F-\u009F\uFEFF\u200B-\u200F\u202A-\u202E\u2060\uFE00-\uFE0F]/g;
534
722
  var toJsonSchema2 = zodToJsonSchema2;
535
723
  var publicToAsoInputSchema = z2.object({
536
724
  slug: z2.string().describe("Product slug"),
@@ -541,121 +729,6 @@ var jsonSchema2 = toJsonSchema2(publicToAsoInputSchema, {
541
729
  $refStrategy: "none"
542
730
  });
543
731
  var inputSchema2 = jsonSchema2.definitions?.PublicToAsoInput || jsonSchema2;
544
- function sanitizeText(value, fieldPath, warnings) {
545
- if (typeof value !== "string") {
546
- return value;
547
- }
548
- const cleaned = value.replace(INVALID_CHAR_REGEX, "");
549
- if (cleaned !== value) {
550
- warnings.push(`Removed invalid characters from ${fieldPath}`);
551
- }
552
- return cleaned;
553
- }
554
- function sanitizeAsoData(configData) {
555
- const sanitizedData = JSON.parse(JSON.stringify(configData));
556
- const warnings = [];
557
- if (sanitizedData.appStore) {
558
- const appStoreData = sanitizedData.appStore;
559
- const locales = isAppStoreMultilingual(appStoreData) ? appStoreData.locales : { [appStoreData.locale || DEFAULT_LOCALE]: appStoreData };
560
- for (const [locale, data] of Object.entries(locales)) {
561
- data.name = sanitizeText(data.name, `App Store [${locale}].name`, warnings);
562
- data.subtitle = sanitizeText(
563
- data.subtitle,
564
- `App Store [${locale}].subtitle`,
565
- warnings
566
- );
567
- data.keywords = sanitizeText(
568
- data.keywords,
569
- `App Store [${locale}].keywords`,
570
- warnings
571
- );
572
- data.promotionalText = sanitizeText(
573
- data.promotionalText,
574
- `App Store [${locale}].promotionalText`,
575
- warnings
576
- );
577
- data.description = sanitizeText(
578
- data.description,
579
- `App Store [${locale}].description`,
580
- warnings
581
- );
582
- data.whatsNew = sanitizeText(
583
- data.whatsNew,
584
- `App Store [${locale}].whatsNew`,
585
- warnings
586
- );
587
- }
588
- }
589
- if (sanitizedData.googlePlay) {
590
- const googlePlayData = sanitizedData.googlePlay;
591
- const locales = isGooglePlayMultilingual(googlePlayData) ? googlePlayData.locales : {
592
- [googlePlayData.defaultLanguage || DEFAULT_LOCALE]: googlePlayData
593
- };
594
- for (const [locale, data] of Object.entries(locales)) {
595
- data.title = sanitizeText(
596
- data.title,
597
- `Google Play [${locale}].title`,
598
- warnings
599
- );
600
- data.shortDescription = sanitizeText(
601
- data.shortDescription,
602
- `Google Play [${locale}].shortDescription`,
603
- warnings
604
- );
605
- data.fullDescription = sanitizeText(
606
- data.fullDescription,
607
- `Google Play [${locale}].fullDescription`,
608
- warnings
609
- );
610
- }
611
- }
612
- return { sanitizedData, warnings };
613
- }
614
- function validateFieldLimits(configData) {
615
- const issues = [];
616
- if (configData.appStore) {
617
- const appStoreData = configData.appStore;
618
- const locales = isAppStoreMultilingual(appStoreData) ? appStoreData.locales : { [appStoreData.locale || DEFAULT_LOCALE]: appStoreData };
619
- for (const [locale, data] of Object.entries(locales)) {
620
- const { name, subtitle, keywords, promotionalText, description, whatsNew } = data;
621
- const push = (field, value, limit) => {
622
- if (typeof value === "string" && value.length > limit) {
623
- issues.push(
624
- `App Store [${locale}].${field}: ${value.length}/${limit}`
625
- );
626
- }
627
- };
628
- push("name", name, APP_STORE_LIMITS.name);
629
- push("subtitle", subtitle, APP_STORE_LIMITS.subtitle);
630
- push("keywords", keywords, APP_STORE_LIMITS.keywords);
631
- push("promotionalText", promotionalText, APP_STORE_LIMITS.promotionalText);
632
- push("description", description, APP_STORE_LIMITS.description);
633
- push("whatsNew", whatsNew, APP_STORE_LIMITS.whatsNew);
634
- }
635
- }
636
- if (configData.googlePlay) {
637
- const googlePlayData = configData.googlePlay;
638
- const locales = isGooglePlayMultilingual(googlePlayData) ? googlePlayData.locales : { [googlePlayData.defaultLanguage || DEFAULT_LOCALE]: googlePlayData };
639
- for (const [locale, data] of Object.entries(locales)) {
640
- const { title, shortDescription, fullDescription } = data;
641
- const push = (field, value, limit) => {
642
- if (typeof value === "string" && value.length > limit) {
643
- issues.push(
644
- `Google Play [${locale}].${field}: ${value.length}/${limit}`
645
- );
646
- }
647
- };
648
- push("title", title, GOOGLE_PLAY_LIMITS.title);
649
- push(
650
- "shortDescription",
651
- shortDescription,
652
- GOOGLE_PLAY_LIMITS.shortDescription
653
- );
654
- push("fullDescription", fullDescription, GOOGLE_PLAY_LIMITS.fullDescription);
655
- }
656
- }
657
- return issues;
658
- }
659
732
  async function downloadScreenshotsToAsoDir(slug, asoData) {
660
733
  const rootDir = getPushDataDir();
661
734
  const productStoreRoot = path4.join(rootDir, "products", slug, "store");
@@ -893,10 +966,7 @@ Possible causes:
893
966
  }
894
967
  const storeData = prepareAsoDataForPush(slug, sanitizedData);
895
968
  const validationIssues = validateFieldLimits(sanitizedData);
896
- const validationMessage = validationIssues.length > 0 ? `\u26A0\uFE0F Field limit issues (see ${FIELD_LIMITS_DOC_PATH}):
897
- - ${validationIssues.join(
898
- "\n- "
899
- )}` : `Field limits OK (checked against ${FIELD_LIMITS_DOC_PATH})`;
969
+ const validationMessage = formatValidationIssues(validationIssues);
900
970
  const pushDataRoot = getPushDataDir();
901
971
  if (dryRun) {
902
972
  return {
@@ -913,7 +983,9 @@ ${JSON.stringify(
913
983
 
914
984
  ${validationMessage}${sanitizeWarnings.length ? `
915
985
  Sanitized invalid characters:
916
- - ${sanitizeWarnings.join("\n- ")}` : ""}`
986
+ - ${sanitizeWarnings.join(
987
+ "\n- "
988
+ )}` : ""}`
917
989
  }
918
990
  ]
919
991
  };
@@ -1530,6 +1602,19 @@ ${researchSections.join("\n")}
1530
1602
 
1531
1603
  `;
1532
1604
  prompt += `**Reference**: ${FIELD_LIMITS_DOC_PATH2}
1605
+
1606
+ `;
1607
+ prompt += `---
1608
+
1609
+ `;
1610
+ prompt += `## Next Step
1611
+
1612
+ `;
1613
+ prompt += `After saving the optimized JSON, proceed to **Stage 2** to optimize other locales:
1614
+ `;
1615
+ prompt += `\`\`\`
1616
+ improve-public(slug="${slug}", stage="2", optimizedPrimary=<the JSON you just created>)
1617
+ \`\`\`
1533
1618
  `;
1534
1619
  return prompt;
1535
1620
  }
@@ -1603,89 +1688,74 @@ ${optimizedPrimary}
1603
1688
  \`\`\`
1604
1689
 
1605
1690
  `;
1691
+ const { keywordResearchFallbackByLocale } = args;
1692
+ const localesNeedingFallback = nonPrimaryLocales.filter((loc) => {
1693
+ const fallbackInfo = keywordResearchFallbackByLocale?.[loc];
1694
+ const researchSections = keywordResearchByLocale[loc] || [];
1695
+ return researchSections.length === 0 || fallbackInfo?.isFallback;
1696
+ });
1606
1697
  const primaryResearchSections = keywordResearchByLocale[primaryLocale] || [];
1607
1698
  const hasPrimaryResearch = primaryResearchSections.length > 0;
1608
1699
  prompt += `## Keyword Research (Per Locale)
1609
1700
 
1701
+ `;
1702
+ prompt += `**Priority:** Use each locale's own keyword research. English fallback is ONLY used when locale-specific research is missing.
1610
1703
  `;
1611
1704
  prompt += `When both iOS and Android research exist for a locale, treat iOS keywords as primary; use Android keywords only if space remains after fitting iOS keywords within character limits.
1612
1705
 
1613
1706
  `;
1614
- if (hasPrimaryResearch) {
1615
- prompt += `**\u{1F4DA} ENGLISH (${primaryLocale}) Keywords - Use as fallback for locales without research (MUST TRANSLATE to target language):**
1616
- ${primaryResearchSections.join("\n")}
1707
+ if (hasPrimaryResearch && localesNeedingFallback.length > 0) {
1708
+ prompt += `---
1709
+ `;
1710
+ prompt += `**\u{1F4DA} ENGLISH FALLBACK (${primaryLocale})** - Only for locales without their own research: ${localesNeedingFallback.join(
1711
+ ", "
1712
+ )}
1713
+ `;
1714
+ prompt += `${primaryResearchSections.join("\n")}
1617
1715
 
1618
1716
  `;
1619
1717
  prompt += `---
1620
1718
 
1621
1719
  `;
1622
1720
  }
1623
- const { keywordResearchFallbackByLocale } = args;
1624
1721
  nonPrimaryLocales.forEach((loc) => {
1625
1722
  const researchSections = keywordResearchByLocale[loc] || [];
1626
1723
  const researchDir = keywordResearchDirByLocale[loc];
1627
1724
  const fallbackInfo = keywordResearchFallbackByLocale?.[loc];
1628
- if (researchSections.length > 0) {
1629
- if (fallbackInfo?.isFallback && fallbackInfo.fallbackLocale) {
1630
- prompt += `### Locale ${loc}: \u{1F504} Using ${fallbackInfo.fallbackLocale} keywords as fallback - MUST TRANSLATE TO ${loc.toUpperCase()}
1725
+ if (researchSections.length > 0 && !fallbackInfo?.isFallback) {
1726
+ prompt += `### Locale ${loc}: \u2705 Using locale-specific keyword research
1631
1727
  `;
1632
- prompt += researchSections.join("\n");
1633
- prompt += `
1728
+ prompt += researchSections.join("\n");
1729
+ prompt += `
1730
+ **Use these ${loc} keywords directly** - they are already in the target language.
1634
1731
 
1635
- **CRITICAL:** The keywords above are in ${fallbackInfo.fallbackLocale}. You MUST:
1636
1732
  `;
1637
- prompt += `1. **TRANSLATE each keyword into ${loc}** - use natural, native expressions
1733
+ } else if (researchSections.length > 0 && fallbackInfo?.isFallback) {
1734
+ prompt += `### Locale ${loc}: \u{1F504} No ${loc} research found - Using ${fallbackInfo.fallbackLocale} as fallback
1638
1735
  `;
1639
- prompt += `2. Ensure translated keywords are what ${loc} users would actually search for
1736
+ prompt += researchSections.join("\n");
1737
+ prompt += `
1738
+ **MUST TRANSLATE:** The keywords above are in ${fallbackInfo.fallbackLocale}. You MUST:
1640
1739
  `;
1641
- prompt += `3. **DO NOT use ${fallbackInfo.fallbackLocale} keywords directly** - all keywords must be in ${loc} language
1642
-
1740
+ prompt += `1. **TRANSLATE each keyword into ${loc}** - use natural, native expressions
1741
+ `;
1742
+ prompt += `2. Ensure translated keywords are what ${loc} users would actually search for
1643
1743
  `;
1644
- } else {
1645
- prompt += `### Locale ${loc}: \u2705 Saved research found (locale-specific)
1646
- ${researchSections.join(
1647
- "\n"
1648
- )}
1744
+ prompt += `3. **DO NOT use ${fallbackInfo.fallbackLocale} keywords directly** - all keywords must be in ${loc} language
1649
1745
 
1650
1746
  `;
1651
- }
1652
1747
  } else if (hasPrimaryResearch) {
1653
- prompt += `### Locale ${loc}: \u26A0\uFE0F No saved research - TRANSLATE ENGLISH KEYWORDS TO ${loc.toUpperCase()}
1748
+ prompt += `### Locale ${loc}: \u26A0\uFE0F No research found - TRANSLATE from English fallback above
1654
1749
  `;
1655
1750
  prompt += `No keyword research found at ${researchDir}.
1656
1751
  `;
1657
- prompt += `**CRITICAL FALLBACK STRATEGY:** You MUST translate English keywords from primary locale (${primaryLocale}) into ${loc}. DO NOT use English keywords directly.
1658
-
1659
- `;
1660
- prompt += `**Translation Steps:**
1661
- `;
1662
- prompt += `1. Take the Tier 1/2/3 keywords from English research above (${primaryLocale})
1663
- `;
1664
- prompt += `2. **TRANSLATE each English keyword into ${loc}** - use natural, native expressions (NOT literal word-for-word translation)
1665
- `;
1666
- prompt += `3. Ensure translated keywords are what ${loc} users would actually search for in their language
1667
- `;
1668
- prompt += `4. Verify translations are culturally appropriate and contextually relevant
1669
- `;
1670
- prompt += `5. Apply the **TRANSLATED** keywords (in ${loc} language) following the same tier strategy
1671
- `;
1672
- prompt += `6. **DO NOT use English keywords in ${loc} locale** - all keywords must be in ${loc} language
1752
+ prompt += `**Use the ENGLISH FALLBACK section above** and TRANSLATE all keywords to ${loc}.
1673
1753
 
1674
1754
  `;
1675
1755
  } else {
1676
- prompt += `### Locale ${loc}: \u26A0\uFE0F No research - TRANSLATE ENGLISH KEYWORDS FROM optimizedPrimary TO ${loc.toUpperCase()}
1677
- `;
1678
- prompt += `No keyword research found. Extract keywords from the optimizedPrimary JSON above and **TRANSLATE them to ${loc}**:
1679
- `;
1680
- prompt += `1. Extract keywords from \`aso.keywords\` in optimizedPrimary (these are in English/${primaryLocale})
1681
- `;
1682
- prompt += `2. **TRANSLATE each English keyword naturally into ${loc}** - use native search expressions
1683
- `;
1684
- prompt += `3. Ensure translated keywords match what ${loc} users would actually search for
1685
- `;
1686
- prompt += `4. Apply the **TRANSLATED** keywords (in ${loc} language) to all ASO fields
1756
+ prompt += `### Locale ${loc}: \u274C No research available
1687
1757
  `;
1688
- prompt += `5. **DO NOT use English keywords in ${loc} locale** - all keywords must be translated to ${loc}
1758
+ prompt += `No keyword research found. Extract keywords from \`aso.keywords\` in optimizedPrimary and **TRANSLATE them to ${loc}**.
1689
1759
 
1690
1760
  `;
1691
1761
  }
@@ -1826,6 +1896,12 @@ ${researchSections.join(
1826
1896
  `;
1827
1897
  } else {
1828
1898
  prompt += `2. All batches completed! \u2705
1899
+ `;
1900
+ prompt += `3. **Run validate-aso** to verify all locales:
1901
+ `;
1902
+ prompt += ` \`\`\`
1903
+ validate-aso(slug="${slug}")
1904
+ \`\`\`
1829
1905
 
1830
1906
  `;
1831
1907
  }
@@ -1901,7 +1977,32 @@ ${researchSections.join(
1901
1977
  prompt += `Repeat for all locales in this batch: ${nonPrimaryLocales.join(
1902
1978
  ", "
1903
1979
  )}
1980
+
1904
1981
  `;
1982
+ const isLastBatch = batchIndex === void 0 || totalBatches && batchIndex + 1 >= totalBatches;
1983
+ if (isLastBatch) {
1984
+ prompt += `---
1985
+
1986
+ `;
1987
+ prompt += `## Final Step: Validate All Locales
1988
+
1989
+ `;
1990
+ prompt += `After completing ALL locale optimizations, run validation:
1991
+ `;
1992
+ prompt += `\`\`\`
1993
+ validate-aso(slug="${slug}")
1994
+ \`\`\`
1995
+
1996
+ `;
1997
+ prompt += `This checks:
1998
+ `;
1999
+ prompt += `- Field length limits (title \u226430, subtitle \u226430, keywords \u2264100, etc.)
2000
+ `;
2001
+ prompt += `- Keyword duplicates
2002
+ `;
2003
+ prompt += `- Invalid characters
2004
+ `;
2005
+ }
1905
2006
  return prompt;
1906
2007
  }
1907
2008
 
@@ -2275,12 +2376,12 @@ This tool returns a PROMPT containing:
2275
2376
 
2276
2377
  ## STAGES
2277
2378
  - **Stage 1:** Primary locale optimization using saved keyword research (ios + android combined)
2278
- - **Stage 2:** Localize to other languages using per-locale research OR translate English keywords
2379
+ - **Stage 2:** Localize to other languages - **each locale uses its OWN keyword research**
2279
2380
 
2280
- ## KEYWORD SOURCES
2281
- - Uses SAVED keyword research from .aso/keywordResearch/products/[slug]/locales/
2282
- - iOS and Android research are automatically combined (iOS prioritized; Android fills remaining slots when limits apply)
2283
- - If locale research is missing, use English keywords and translate
2381
+ ## KEYWORD SOURCES (Per Locale)
2382
+ - **Priority 1:** Uses each locale's SAVED keyword research from .aso/keywordResearch/products/[slug]/locales/[locale]/
2383
+ - **Priority 2 (Fallback):** If locale-specific research is missing, falls back to en-US/en keywords and TRANSLATES them
2384
+ - iOS and Android research are automatically combined per locale (iOS prioritized)
2284
2385
 
2285
2386
  **CRITICAL:** Only processes existing locale files. Does NOT create new files.`,
2286
2387
  inputSchema: inputSchema3
@@ -3573,6 +3674,177 @@ Context around ${pos}: ${context}`
3573
3674
  };
3574
3675
  }
3575
3676
 
3677
+ // src/tools/validate-aso.ts
3678
+ import { z as z8 } from "zod";
3679
+ import { zodToJsonSchema as zodToJsonSchema8 } from "zod-to-json-schema";
3680
+ var toJsonSchema5 = zodToJsonSchema8;
3681
+ var validateAsoInputSchema = z8.object({
3682
+ slug: z8.string().describe("Product slug"),
3683
+ locale: z8.string().optional().describe("Specific locale to validate (default: all locales)"),
3684
+ fix: z8.boolean().optional().default(false).describe("Auto-fix issues where possible (e.g., remove invalid chars)")
3685
+ });
3686
+ var jsonSchema8 = toJsonSchema5(validateAsoInputSchema, {
3687
+ name: "ValidateAsoInput",
3688
+ $refStrategy: "none"
3689
+ });
3690
+ var inputSchema8 = jsonSchema8.definitions?.ValidateAsoInput || jsonSchema8;
3691
+ var validateAsoTool = {
3692
+ name: "validate-aso",
3693
+ description: `Validates ASO data against App Store / Google Play field limits and rules.
3694
+
3695
+ **IMPORTANT:** Use 'search-app' tool first to resolve the exact slug.
3696
+
3697
+ ## WHAT IT VALIDATES
3698
+ 1. **Field Length Limits** (${FIELD_LIMITS_DOC_PATH}):
3699
+ - App Store: name \u2264${APP_STORE_LIMITS.name}, subtitle \u2264${APP_STORE_LIMITS.subtitle}, keywords \u2264${APP_STORE_LIMITS.keywords}, description \u2264${APP_STORE_LIMITS.description}
3700
+ - Google Play: title \u2264${GOOGLE_PLAY_LIMITS.title}, shortDescription \u2264${GOOGLE_PLAY_LIMITS.shortDescription}, fullDescription \u2264${GOOGLE_PLAY_LIMITS.fullDescription}
3701
+
3702
+ 2. **Keyword Duplicates** (App Store only):
3703
+ - Checks for duplicate keywords in comma-separated list
3704
+
3705
+ 3. **Invalid Characters**:
3706
+ - Control characters, BOM, zero-width/invisible characters, variation selectors
3707
+
3708
+ ## WHEN TO USE
3709
+ - After running improve-public Stage 1/2 to verify optimization results
3710
+ - Before running public-to-aso to ensure data is valid
3711
+ - Anytime you want to check ASO data validity
3712
+
3713
+ ## OPTIONS
3714
+ - \`locale\`: Validate specific locale only (e.g., "ko-KR")
3715
+ - \`fix\`: Auto-fix issues where possible (removes invalid characters)`,
3716
+ inputSchema: inputSchema8
3717
+ };
3718
+ function getLocaleStats(configData) {
3719
+ const stats = [];
3720
+ if (configData.appStore) {
3721
+ const appStoreData = configData.appStore;
3722
+ const locales = isAppStoreMultilingual(appStoreData) ? appStoreData.locales : { [appStoreData.locale || DEFAULT_LOCALE]: appStoreData };
3723
+ for (const [locale, data] of Object.entries(locales)) {
3724
+ const fields = [];
3725
+ const checkField = (field, value, limit) => {
3726
+ const length = value?.length || 0;
3727
+ let status = "ok";
3728
+ if (length > limit) status = "error";
3729
+ else if (length > limit * 0.9) status = "warning";
3730
+ fields.push({ field, length, limit, status });
3731
+ };
3732
+ checkField("name", data.name, APP_STORE_LIMITS.name);
3733
+ checkField("subtitle", data.subtitle, APP_STORE_LIMITS.subtitle);
3734
+ checkField("keywords", data.keywords, APP_STORE_LIMITS.keywords);
3735
+ checkField("promotionalText", data.promotionalText, APP_STORE_LIMITS.promotionalText);
3736
+ checkField("description", data.description, APP_STORE_LIMITS.description);
3737
+ stats.push({ locale, store: "appStore", fields });
3738
+ }
3739
+ }
3740
+ if (configData.googlePlay) {
3741
+ const googlePlayData = configData.googlePlay;
3742
+ const locales = isGooglePlayMultilingual(googlePlayData) ? googlePlayData.locales : { [googlePlayData.defaultLanguage || DEFAULT_LOCALE]: googlePlayData };
3743
+ for (const [locale, data] of Object.entries(locales)) {
3744
+ const fields = [];
3745
+ const checkField = (field, value, limit) => {
3746
+ const length = value?.length || 0;
3747
+ let status = "ok";
3748
+ if (length > limit) status = "error";
3749
+ else if (length > limit * 0.9) status = "warning";
3750
+ fields.push({ field, length, limit, status });
3751
+ };
3752
+ checkField("title", data.title, GOOGLE_PLAY_LIMITS.title);
3753
+ checkField("shortDescription", data.shortDescription, GOOGLE_PLAY_LIMITS.shortDescription);
3754
+ checkField("fullDescription", data.fullDescription, GOOGLE_PLAY_LIMITS.fullDescription);
3755
+ stats.push({ locale, store: "googlePlay", fields });
3756
+ }
3757
+ }
3758
+ return stats;
3759
+ }
3760
+ function formatStats(stats, filterLocale) {
3761
+ const filteredStats = filterLocale ? stats.filter((s) => s.locale === filterLocale) : stats;
3762
+ if (filteredStats.length === 0) {
3763
+ return filterLocale ? `No data found for locale: ${filterLocale}` : "No ASO data found";
3764
+ }
3765
+ const lines = ["## Field Length Report\n"];
3766
+ for (const stat of filteredStats) {
3767
+ const storeLabel = stat.store === "appStore" ? "App Store" : "Google Play";
3768
+ lines.push(`### ${storeLabel} [${stat.locale}]
3769
+ `);
3770
+ lines.push("| Field | Length | Limit | Status |");
3771
+ lines.push("|-------|--------|-------|--------|");
3772
+ for (const field of stat.fields) {
3773
+ const statusEmoji = field.status === "error" ? "\u274C" : field.status === "warning" ? "\u26A0\uFE0F" : "\u2705";
3774
+ lines.push(
3775
+ `| ${field.field} | ${field.length} | ${field.limit} | ${statusEmoji} |`
3776
+ );
3777
+ }
3778
+ lines.push("");
3779
+ }
3780
+ return lines.join("\n");
3781
+ }
3782
+ async function handleValidateAso(input) {
3783
+ const { slug, locale, fix } = input;
3784
+ const configData = loadAsoFromConfig(slug);
3785
+ if (!configData.googlePlay && !configData.appStore) {
3786
+ throw new Error(`No ASO data found for ${slug}`);
3787
+ }
3788
+ const results = [];
3789
+ results.push(`# ASO Validation Report: ${slug}
3790
+ `);
3791
+ const { sanitizedData, warnings: sanitizeWarnings } = sanitizeAsoData(configData);
3792
+ if (sanitizeWarnings.length > 0) {
3793
+ results.push(`## Invalid Characters Found
3794
+ `);
3795
+ if (fix) {
3796
+ results.push(
3797
+ `The following invalid characters were ${fix ? "removed" : "detected"}:
3798
+ `
3799
+ );
3800
+ }
3801
+ for (const warning of sanitizeWarnings) {
3802
+ results.push(`- ${warning}`);
3803
+ }
3804
+ results.push("");
3805
+ }
3806
+ const dataToValidate = fix ? sanitizedData : configData;
3807
+ const limitIssues = validateFieldLimits(dataToValidate);
3808
+ const filteredIssues = locale ? limitIssues.filter((issue) => issue.locale === locale) : limitIssues;
3809
+ results.push(formatValidationIssues(filteredIssues));
3810
+ results.push("");
3811
+ const keywordIssues = validateKeywords(dataToValidate);
3812
+ const filteredKeywordIssues = locale ? keywordIssues.filter((issue) => issue.locale === locale) : keywordIssues;
3813
+ if (filteredKeywordIssues.length > 0) {
3814
+ results.push(`## Keyword Duplicates
3815
+ `);
3816
+ for (const issue of filteredKeywordIssues) {
3817
+ results.push(
3818
+ `- [${issue.locale}]: ${issue.duplicates.join(", ")}`
3819
+ );
3820
+ }
3821
+ results.push("");
3822
+ }
3823
+ const stats = getLocaleStats(dataToValidate);
3824
+ results.push(formatStats(stats, locale));
3825
+ const hasErrors = filteredIssues.length > 0 || filteredKeywordIssues.length > 0;
3826
+ const hasSanitizeWarnings = sanitizeWarnings.length > 0;
3827
+ results.push(`---
3828
+ `);
3829
+ if (hasErrors) {
3830
+ results.push(`\u274C **Validation failed** - Fix the issues above before pushing to stores.`);
3831
+ results.push(`
3832
+ Reference: ${FIELD_LIMITS_DOC_PATH}`);
3833
+ } else if (hasSanitizeWarnings && !fix) {
3834
+ results.push(`\u26A0\uFE0F **Invalid characters detected** - Run with \`fix: true\` to auto-remove.`);
3835
+ } else {
3836
+ results.push(`\u2705 **Validation passed** - Ready to push to stores.`);
3837
+ }
3838
+ return {
3839
+ content: [
3840
+ {
3841
+ type: "text",
3842
+ text: results.join("\n")
3843
+ }
3844
+ ]
3845
+ };
3846
+ }
3847
+
3576
3848
  // src/tools/index.ts
3577
3849
  var tools = [
3578
3850
  {
@@ -3630,6 +3902,14 @@ var tools = [
3630
3902
  zodSchema: searchAppInputSchema,
3631
3903
  handler: handleSearchApp,
3632
3904
  category: "App Management"
3905
+ },
3906
+ {
3907
+ name: validateAsoTool.name,
3908
+ description: validateAsoTool.description,
3909
+ inputSchema: validateAsoTool.inputSchema,
3910
+ zodSchema: validateAsoInputSchema,
3911
+ handler: handleValidateAso,
3912
+ category: "ASO Validation"
3633
3913
  }
3634
3914
  ];
3635
3915
  function getToolDefinitions() {
@@ -3640,7 +3920,8 @@ function getToolDefinitions() {
3640
3920
  initProjectTool,
3641
3921
  createBlogHtmlTool,
3642
3922
  keywordResearchTool,
3643
- searchAppTool
3923
+ searchAppTool,
3924
+ validateAsoTool
3644
3925
  ];
3645
3926
  }
3646
3927
  function getToolHandler(name) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pabal-web-mcp",
3
- "version": "1.4.6",
3
+ "version": "1.4.7",
4
4
  "type": "module",
5
5
  "description": "MCP server for ASO data management with shared types and utilities",
6
6
  "author": "skyu",