pabal-web-mcp 1.4.5 → 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 +527 -181
  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,17 +1688,30 @@ ${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 += `---
@@ -1623,50 +1721,41 @@ ${primaryResearchSections.join("\n")}
1623
1721
  nonPrimaryLocales.forEach((loc) => {
1624
1722
  const researchSections = keywordResearchByLocale[loc] || [];
1625
1723
  const researchDir = keywordResearchDirByLocale[loc];
1626
- if (researchSections.length > 0) {
1627
- prompt += `### Locale ${loc}: \u2705 Saved research found
1628
- ${researchSections.join(
1629
- "\n"
1630
- )}
1631
-
1724
+ const fallbackInfo = keywordResearchFallbackByLocale?.[loc];
1725
+ if (researchSections.length > 0 && !fallbackInfo?.isFallback) {
1726
+ prompt += `### Locale ${loc}: \u2705 Using locale-specific keyword research
1632
1727
  `;
1633
- } else if (hasPrimaryResearch) {
1634
- prompt += `### Locale ${loc}: \u26A0\uFE0F No saved research - TRANSLATE ENGLISH KEYWORDS TO ${loc.toUpperCase()}
1635
- `;
1636
- prompt += `No keyword research found at ${researchDir}.
1637
- `;
1638
- prompt += `**CRITICAL FALLBACK STRATEGY:** You MUST translate English keywords from primary locale (${primaryLocale}) into ${loc}. DO NOT use English keywords directly.
1728
+ prompt += researchSections.join("\n");
1729
+ prompt += `
1730
+ **Use these ${loc} keywords directly** - they are already in the target language.
1639
1731
 
1640
1732
  `;
1641
- prompt += `**Translation Steps:**
1642
- `;
1643
- prompt += `1. Take the Tier 1/2/3 keywords from English research above (${primaryLocale})
1644
- `;
1645
- prompt += `2. **TRANSLATE each English keyword into ${loc}** - use natural, native expressions (NOT literal word-for-word translation)
1733
+ } else if (researchSections.length > 0 && fallbackInfo?.isFallback) {
1734
+ prompt += `### Locale ${loc}: \u{1F504} No ${loc} research found - Using ${fallbackInfo.fallbackLocale} as fallback
1646
1735
  `;
1647
- prompt += `3. Ensure translated keywords are what ${loc} users would actually search for in their language
1736
+ prompt += researchSections.join("\n");
1737
+ prompt += `
1738
+ **MUST TRANSLATE:** The keywords above are in ${fallbackInfo.fallbackLocale}. You MUST:
1648
1739
  `;
1649
- prompt += `4. Verify translations are culturally appropriate and contextually relevant
1740
+ prompt += `1. **TRANSLATE each keyword into ${loc}** - use natural, native expressions
1650
1741
  `;
1651
- prompt += `5. Apply the **TRANSLATED** keywords (in ${loc} language) following the same tier strategy
1742
+ prompt += `2. Ensure translated keywords are what ${loc} users would actually search for
1652
1743
  `;
1653
- prompt += `6. **DO NOT use English keywords in ${loc} locale** - all keywords must be in ${loc} language
1744
+ prompt += `3. **DO NOT use ${fallbackInfo.fallbackLocale} keywords directly** - all keywords must be in ${loc} language
1654
1745
 
1655
1746
  `;
1656
- } else {
1657
- prompt += `### Locale ${loc}: \u26A0\uFE0F No research - TRANSLATE ENGLISH KEYWORDS FROM optimizedPrimary TO ${loc.toUpperCase()}
1658
- `;
1659
- prompt += `No keyword research found. Extract keywords from the optimizedPrimary JSON above and **TRANSLATE them to ${loc}**:
1660
- `;
1661
- prompt += `1. Extract keywords from \`aso.keywords\` in optimizedPrimary (these are in English/${primaryLocale})
1747
+ } else if (hasPrimaryResearch) {
1748
+ prompt += `### Locale ${loc}: \u26A0\uFE0F No research found - TRANSLATE from English fallback above
1662
1749
  `;
1663
- prompt += `2. **TRANSLATE each English keyword naturally into ${loc}** - use native search expressions
1750
+ prompt += `No keyword research found at ${researchDir}.
1664
1751
  `;
1665
- prompt += `3. Ensure translated keywords match what ${loc} users would actually search for
1752
+ prompt += `**Use the ENGLISH FALLBACK section above** and TRANSLATE all keywords to ${loc}.
1753
+
1666
1754
  `;
1667
- prompt += `4. Apply the **TRANSLATED** keywords (in ${loc} language) to all ASO fields
1755
+ } else {
1756
+ prompt += `### Locale ${loc}: \u274C No research available
1668
1757
  `;
1669
- 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}**.
1670
1759
 
1671
1760
  `;
1672
1761
  }
@@ -1807,6 +1896,12 @@ ${researchSections.join(
1807
1896
  `;
1808
1897
  } else {
1809
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
+ \`\`\`
1810
1905
 
1811
1906
  `;
1812
1907
  }
@@ -1882,7 +1977,32 @@ ${researchSections.join(
1882
1977
  prompt += `Repeat for all locales in this batch: ${nonPrimaryLocales.join(
1883
1978
  ", "
1884
1979
  )}
1980
+
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
1885
2004
  `;
2005
+ }
1886
2006
  return prompt;
1887
2007
  }
1888
2008
 
@@ -2130,7 +2250,7 @@ function formatMergedData(merged, researchDir) {
2130
2250
  lines.push("\n----");
2131
2251
  return lines.join("\n");
2132
2252
  }
2133
- function loadKeywordResearchForLocale(slug, locale) {
2253
+ function loadKeywordResearchForLocaleInternal(slug, locale) {
2134
2254
  const researchDir = path6.join(
2135
2255
  getKeywordResearchDir(),
2136
2256
  "products",
@@ -2139,9 +2259,12 @@ function loadKeywordResearchForLocale(slug, locale) {
2139
2259
  locale
2140
2260
  );
2141
2261
  if (!fs6.existsSync(researchDir)) {
2142
- return { entries: [], sections: [], researchDir };
2262
+ return null;
2143
2263
  }
2144
2264
  const files = fs6.readdirSync(researchDir).filter((file) => file.endsWith(".json"));
2265
+ if (files.length === 0) {
2266
+ return null;
2267
+ }
2145
2268
  const entries = [];
2146
2269
  for (const file of files) {
2147
2270
  const filePath = path6.join(researchDir, file);
@@ -2159,17 +2282,53 @@ function loadKeywordResearchForLocale(slug, locale) {
2159
2282
  }
2160
2283
  }
2161
2284
  const validEntries = entries.filter((e) => !e.data?.parseError);
2285
+ if (validEntries.length === 0) {
2286
+ return null;
2287
+ }
2162
2288
  if (validEntries.length > 1) {
2163
2289
  const merged = mergeKeywordData(validEntries);
2164
2290
  const mergedSection = formatMergedData(merged, researchDir);
2165
2291
  return { entries, sections: [mergedSection], researchDir };
2166
- } else if (validEntries.length === 1) {
2167
- const sections2 = entries.map(formatEntry);
2168
- return { entries, sections: sections2, researchDir };
2169
2292
  }
2170
2293
  const sections = entries.map(formatEntry);
2171
2294
  return { entries, sections, researchDir };
2172
2295
  }
2296
+ var FALLBACK_LOCALES = ["en-US", "en"];
2297
+ function loadKeywordResearchForLocale(slug, locale) {
2298
+ const researchDir = path6.join(
2299
+ getKeywordResearchDir(),
2300
+ "products",
2301
+ slug,
2302
+ "locales",
2303
+ locale
2304
+ );
2305
+ const result = loadKeywordResearchForLocaleInternal(slug, locale);
2306
+ if (result) {
2307
+ return { ...result, isFallback: false };
2308
+ }
2309
+ for (const fallbackLocale of FALLBACK_LOCALES) {
2310
+ if (fallbackLocale === locale) continue;
2311
+ const fallbackResult = loadKeywordResearchForLocaleInternal(
2312
+ slug,
2313
+ fallbackLocale
2314
+ );
2315
+ if (fallbackResult) {
2316
+ const fallbackNotice = `\u26A0\uFE0F **FALLBACK: Using ${fallbackLocale} keywords** - No research found for ${locale}. You MUST TRANSLATE these keywords to ${locale}.
2317
+ `;
2318
+ const sectionsWithNotice = fallbackResult.sections.map(
2319
+ (section) => fallbackNotice + section
2320
+ );
2321
+ return {
2322
+ entries: fallbackResult.entries,
2323
+ sections: sectionsWithNotice,
2324
+ researchDir: fallbackResult.researchDir,
2325
+ isFallback: true,
2326
+ fallbackLocale
2327
+ };
2328
+ }
2329
+ }
2330
+ return { entries: [], sections: [], researchDir, isFallback: false };
2331
+ }
2173
2332
 
2174
2333
  // src/tools/improve-public.ts
2175
2334
  var toJsonSchema3 = zodToJsonSchema3;
@@ -2217,12 +2376,12 @@ This tool returns a PROMPT containing:
2217
2376
 
2218
2377
  ## STAGES
2219
2378
  - **Stage 1:** Primary locale optimization using saved keyword research (ios + android combined)
2220
- - **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**
2221
2380
 
2222
- ## KEYWORD SOURCES
2223
- - Uses SAVED keyword research from .aso/keywordResearch/products/[slug]/locales/
2224
- - iOS and Android research are automatically combined (iOS prioritized; Android fills remaining slots when limits apply)
2225
- - 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)
2226
2385
 
2227
2386
  **CRITICAL:** Only processes existing locale files. Does NOT create new files.`,
2228
2387
  inputSchema: inputSchema3
@@ -2279,10 +2438,15 @@ async function handleImprovePublic(input) {
2279
2438
  }
2280
2439
  const keywordResearchByLocale = {};
2281
2440
  const keywordResearchDirByLocale = {};
2441
+ const keywordResearchFallbackByLocale = {};
2282
2442
  for (const loc of targetLocales) {
2283
2443
  const research = loadKeywordResearchForLocale(slug, loc);
2284
2444
  keywordResearchByLocale[loc] = research.sections;
2285
2445
  keywordResearchDirByLocale[loc] = research.researchDir;
2446
+ keywordResearchFallbackByLocale[loc] = {
2447
+ isFallback: research.isFallback,
2448
+ fallbackLocale: research.fallbackLocale
2449
+ };
2286
2450
  }
2287
2451
  const baseArgs = {
2288
2452
  slug,
@@ -2291,7 +2455,8 @@ async function handleImprovePublic(input) {
2291
2455
  targetLocales,
2292
2456
  localeSections,
2293
2457
  keywordResearchByLocale,
2294
- keywordResearchDirByLocale
2458
+ keywordResearchDirByLocale,
2459
+ keywordResearchFallbackByLocale
2295
2460
  };
2296
2461
  if (stage === "1" || stage === "both") {
2297
2462
  const prompt = generatePrimaryOptimizationPrompt(baseArgs);
@@ -2339,6 +2504,7 @@ async function handleImprovePublic(input) {
2339
2504
  localeSections: baseArgs.localeSections,
2340
2505
  keywordResearchByLocale: baseArgs.keywordResearchByLocale,
2341
2506
  keywordResearchDirByLocale: baseArgs.keywordResearchDirByLocale,
2507
+ keywordResearchFallbackByLocale: baseArgs.keywordResearchFallbackByLocale,
2342
2508
  optimizedPrimary,
2343
2509
  batchLocales,
2344
2510
  batchIndex: currentBatchIndex,
@@ -3508,6 +3674,177 @@ Context around ${pos}: ${context}`
3508
3674
  };
3509
3675
  }
3510
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
+
3511
3848
  // src/tools/index.ts
3512
3849
  var tools = [
3513
3850
  {
@@ -3565,6 +3902,14 @@ var tools = [
3565
3902
  zodSchema: searchAppInputSchema,
3566
3903
  handler: handleSearchApp,
3567
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"
3568
3913
  }
3569
3914
  ];
3570
3915
  function getToolDefinitions() {
@@ -3575,7 +3920,8 @@ function getToolDefinitions() {
3575
3920
  initProjectTool,
3576
3921
  createBlogHtmlTool,
3577
3922
  keywordResearchTool,
3578
- searchAppTool
3923
+ searchAppTool,
3924
+ validateAsoTool
3579
3925
  ];
3580
3926
  }
3581
3927
  function getToolHandler(name) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pabal-web-mcp",
3
- "version": "1.4.5",
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",