pabal-resource-mcp 1.10.5 → 1.10.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.
@@ -14,7 +14,7 @@ import {
14
14
  getPushDataDir,
15
15
  loadAsoFromConfig,
16
16
  saveAsoToAsoDir
17
- } from "../chunk-4JFBWKY4.js";
17
+ } from "../chunk-H4MYWLFK.js";
18
18
  import {
19
19
  DEFAULT_LOCALE,
20
20
  appStoreToUnified,
@@ -189,7 +189,7 @@ This locale has data from BOTH Google Play and App Store. Use the following merg
189
189
  - Keep proper nouns and technical terms in English when translating
190
190
  - Keep terms like "Always-On Display", "E-Ink", "Android", brand names (e.g., "Boox", "Meebook"), and other technical/proper nouns in English
191
191
  - Only translate descriptive text, not technical terminology or proper nouns
192
- - Example: "Always-On Display" should remain "Always-On Display" in Korean, not translated to "\uD56D\uC0C1 \uCF1C\uC9C4 \uD654\uBA74"
192
+ - Example: "Always-On Display" should remain "Always-On Display" in translated locales, not translated into a generic phrase like "screen that is always on"
193
193
 
194
194
  ${screenshotPaths ? `
195
195
  **Screenshot Paths:**
@@ -358,6 +358,7 @@ import path4 from "path";
358
358
 
359
359
  // src/utils/aso-validation.util.ts
360
360
  var FIELD_LIMITS_DOC_PATH = "docs/aso/ASO_FIELD_LIMITS.md";
361
+ var ASO_OVERVIEW_DOC_PATH = "docs/aso/ASO_OVERVIEW.md";
361
362
  var APP_STORE_LIMITS = {
362
363
  name: 30,
363
364
  subtitle: 30,
@@ -542,6 +543,32 @@ function checkKeywordDuplicates(keywords) {
542
543
  uniqueKeywords
543
544
  };
544
545
  }
546
+ var normalizeKeyword = (value) => value.trim().toLowerCase();
547
+ function getTitleAndSubtitleKeywordSet(name, subtitle) {
548
+ const sourceText = [name, subtitle].filter((value) => Boolean(value)).join(" ").toLowerCase();
549
+ const terms = sourceText.split(/[^\p{L}\p{N}]+/u).map((term) => term.trim()).filter(Boolean);
550
+ return new Set(terms);
551
+ }
552
+ function validateKeywordRules(keywords, name, subtitle) {
553
+ const duplicateResult = checkKeywordDuplicates(keywords);
554
+ const keywordList = keywords.split(",").map(normalizeKeyword).filter(Boolean);
555
+ const titleAndSubtitleKeywords = getTitleAndSubtitleKeywordSet(
556
+ name,
557
+ subtitle
558
+ );
559
+ const formatting = [];
560
+ if (/\s/.test(keywords)) {
561
+ formatting.push("Use comma-only keywords with no spaces");
562
+ }
563
+ const repeatedFromTitleOrSubtitle = keywordList.filter((keyword) => {
564
+ return titleAndSubtitleKeywords.has(keyword);
565
+ });
566
+ return {
567
+ duplicates: duplicateResult.duplicates,
568
+ formatting,
569
+ repeatedFromTitleOrSubtitle
570
+ };
571
+ }
545
572
  function validateKeywords(configData) {
546
573
  const issues = [];
547
574
  if (configData.appStore) {
@@ -549,9 +576,14 @@ function validateKeywords(configData) {
549
576
  const locales = isAppStoreMultilingual(appStoreData) ? appStoreData.locales : { [appStoreData.locale || DEFAULT_LOCALE]: appStoreData };
550
577
  for (const [locale, data] of Object.entries(locales)) {
551
578
  if (data.keywords) {
552
- const result = checkKeywordDuplicates(data.keywords);
553
- if (result.hasDuplicates) {
554
- issues.push({ locale, duplicates: result.duplicates });
579
+ const result = validateKeywordRules(
580
+ data.keywords,
581
+ data.name,
582
+ data.subtitle
583
+ );
584
+ const hasIssues = result.duplicates.length > 0 || result.formatting.length > 0 || result.repeatedFromTitleOrSubtitle.length > 0;
585
+ if (hasIssues) {
586
+ issues.push({ locale, ...result });
555
587
  }
556
588
  }
557
589
  }
@@ -894,7 +926,7 @@ This tool:
894
926
  4. Copies/downloads screenshots to .aso/pushData/products/[slug]/store/screenshots/
895
927
  5. Validates text field lengths against ${FIELD_LIMITS_DOC_PATH} (fails if over limits)
896
928
 
897
- Before running, review ${FIELD_LIMITS_DOC_PATH} for per-store limits. This prepares data for pushing to stores without actually uploading.`,
929
+ Before running, review ${ASO_OVERVIEW_DOC_PATH} for ASO strategy and ${FIELD_LIMITS_DOC_PATH} for per-store limits. This prepares data for pushing to stores without actually uploading.`,
898
930
  inputSchema: inputSchema2
899
931
  };
900
932
  async function handlePublicToAso(input) {
@@ -1049,7 +1081,7 @@ ${validationMessage}`
1049
1081
  responseText += `
1050
1082
  Next step: Push to stores using pabal-store-api-mcp's aso-push tool`;
1051
1083
  responseText += `
1052
- Reference: ${FIELD_LIMITS_DOC_PATH}`;
1084
+ References: ${ASO_OVERVIEW_DOC_PATH}, ${FIELD_LIMITS_DOC_PATH}`;
1053
1085
  if (sanitizeWarnings.length > 0) {
1054
1086
  responseText += `
1055
1087
  Sanitized invalid characters:
@@ -1349,7 +1381,11 @@ function generateKeywordSuggestions(args) {
1349
1381
  `;
1350
1382
  suggestions += `6. **template.intro**: Use up to 300 chars to naturally incorporate more keywords and provide richer context
1351
1383
  `;
1352
- suggestions += `7. **App Store Keywords**: 100 char limit, comma-separated, avoid duplicates from name/subtitle
1384
+ suggestions += `7. **App Store Keywords**: 100 char limit, comma-separated with commas only/no spaces, avoid duplicates from name/subtitle, prefer singular forms, order important keywords first, and fill as close to 100 chars as possible with relevant keywords only
1385
+ `;
1386
+ suggestions += `8. **Keyword Selection**: Follow ${ASO_OVERVIEW_DOC_PATH}: popularity >=20 when available, achievable difficulty, beatable top-10 competitors, strong relevance, and target-user search intent
1387
+ `;
1388
+ suggestions += `9. **Field Limits**: Follow ${FIELD_LIMITS_DOC_PATH}
1353
1389
 
1354
1390
  `;
1355
1391
  if (category) {
@@ -1385,7 +1421,7 @@ function formatLocaleSection(args) {
1385
1421
  const screenshots = landing.screenshots?.images || [];
1386
1422
  const features = landing.features?.items || [];
1387
1423
  const lengthOf = (value) => value ? value.length : 0;
1388
- const keywordsLength = Array.isArray(aso.keywords) ? aso.keywords.join(", ").length : lengthOf(typeof aso.keywords === "string" ? aso.keywords : void 0);
1424
+ const keywordsLength = Array.isArray(aso.keywords) ? aso.keywords.join(",").length : lengthOf(typeof aso.keywords === "string" ? aso.keywords : void 0);
1389
1425
  const header = `--- ${locale}${locale === primaryLocale ? " (primary)" : ""} ---`;
1390
1426
  const stats = [
1391
1427
  `Path: public/products/${slug}/locales/${locale}.json`,
@@ -1442,7 +1478,24 @@ ${json}
1442
1478
  }
1443
1479
 
1444
1480
  // src/tools/aso/utils/improve/generate-aso-prompt.util.ts
1445
- var FIELD_LIMITS_DOC_PATH2 = "docs/aso/ASO_FIELD_LIMITS.md";
1481
+ var ASO_RULES_SUMMARY = [
1482
+ `Use ${ASO_OVERVIEW_DOC_PATH} for keyword strategy and ${FIELD_LIMITS_DOC_PATH} for hard limits.`,
1483
+ "Keyword source priority: product-level manual CSV files in `.aso/keywordResearch/products/[slug]/*.csv` first. Check the target country Store Domain first; if missing, translate/localize US CSV keywords for the target locale. Then use locale saved keyword research alongside the CSV to validate and fill remaining opportunities.",
1484
+ "Distribute important terms in this order: title first, subtitle second, keywords third.",
1485
+ "Assign tracked keywords by relevance and current rank: best keyword that fits 30 chars -> title; next best keyword that fits 30 chars -> subtitle; remaining high-value keywords within 100 chars -> keyword field.",
1486
+ "`aso.title`: keep app name + the most relevant high-priority keyword, usually `App Name: Primary Keyword`; remember `&`, `:`, and `-` count as 2 characters in title limits.",
1487
+ "`aso.subtitle`: use important keywords that do not repeat the title.",
1488
+ "`aso.keywords`: comma-separated with commas only, no spaces, no duplicates, no title/subtitle repetition; every word can appear in only one of title, subtitle, or keywords.",
1489
+ "Do not split multi-word keywords across fields; keep the full phrase in one field.",
1490
+ "Use singular forms only because Apple indexes plurals automatically.",
1491
+ "Exclude stop words that Apple ignores, such as `a`, `and`, `the`, `for`, `with`, `app`, and `to`.",
1492
+ "Exclude company/app names and inherited category terms, such as the app brand and categories like `Health & Fitness`.",
1493
+ "For US Store keyword suggestions, find suggestions for the app and only add keywords with popularity >25 and difficulty <75.",
1494
+ "`aso.keywords`: use as much of the 100-character limit as possible with relevant keywords; do not leave meaningful capacity unused and do not use filler.",
1495
+ '`aso.keywords`: split phrase intent into reusable single terms when appropriate, e.g. `sound,relaxing,rain` for "relaxing sound" + "rain sound".',
1496
+ "`aso.keywords`: prefer singular forms, allow real searched misspellings, and order by importance.",
1497
+ "Choose keywords with popularity >=20, achievable difficulty, beatable top-10 competitors, strong relevance, and likely target-user search intent."
1498
+ ].join("\n- ");
1446
1499
  function generatePrimaryOptimizationPrompt(args) {
1447
1500
  const {
1448
1501
  slug,
@@ -1457,23 +1510,34 @@ function generatePrimaryOptimizationPrompt(args) {
1457
1510
  `;
1458
1511
  prompt += `Product: ${slug} | Category: ${category || "N/A"} | Primary: ${primaryLocale}
1459
1512
 
1513
+ `;
1514
+ prompt += `## ASO Basics
1515
+
1516
+ `;
1517
+ prompt += `- ${ASO_RULES_SUMMARY}
1518
+
1460
1519
  `;
1461
1520
  prompt += `## Task
1462
1521
 
1463
1522
  `;
1464
- prompt += `Optimize the PRIMARY locale (${primaryLocale}) using **saved keyword research** + full ASO field optimization.
1523
+ prompt += `Optimize the PRIMARY locale (${primaryLocale}) using **manual CSV priority keywords first**, then saved keyword research + full ASO field optimization.
1465
1524
 
1466
1525
  `;
1467
- prompt += `## Step 1: Use Saved Keyword Research (${primaryLocale})
1526
+ prompt += `## Step 1: Use Keyword Sources (${primaryLocale})
1468
1527
 
1469
1528
  `;
1470
1529
  const researchSections = keywordResearchByLocale[primaryLocale] || [];
1471
1530
  const researchDir = keywordResearchDirByLocale[primaryLocale];
1472
1531
  if (researchSections.length > 0) {
1473
- prompt += `**CRITICAL: Use ONLY the saved keyword research below. Do NOT invent or research new keywords.**
1532
+ prompt += `**CRITICAL: Use ONLY the keyword sources below. Do NOT invent or research new keywords.**
1533
+
1534
+ `;
1535
+ prompt += `**Source priority:** If a "Manual Priority Keywords CSV" section exists, apply those keywords before generated keyword research. If the CSV section says it is using US fallback rows, translate/localize those US keywords into ${primaryLocale} before applying them. Still review the saved locale keyword research alongside the CSV to validate relevance and fill remaining relevant opportunities.
1474
1536
 
1475
1537
  `;
1476
1538
  prompt += `The research data includes:
1539
+ `;
1540
+ prompt += `- **Manual Priority Keywords CSV:** Human-curated product-level keyword list. Check target country rows first; if missing, use US rows as a translation source. Apply before saved research, while still considering saved research.
1477
1541
  `;
1478
1542
  prompt += `- **Tier 1 (Core):** Use these in title and subtitle - highest traffic, best opportunity
1479
1543
  `;
@@ -1490,7 +1554,7 @@ function generatePrimaryOptimizationPrompt(args) {
1490
1554
  prompt += `- **User Language Patterns:** Phrases real users use in reviews - incorporate naturally
1491
1555
 
1492
1556
  `;
1493
- prompt += `Saved research:
1557
+ prompt += `Keyword sources:
1494
1558
  ${researchSections.join("\n")}
1495
1559
 
1496
1560
  `;
@@ -1509,23 +1573,35 @@ ${researchSections.join("\n")}
1509
1573
  `;
1510
1574
  prompt += `**Apply keywords strategically based on tier priority:**
1511
1575
 
1576
+ `;
1577
+ prompt += `**Assignment rule:** Tag tracked keywords by relevance and current rank: best keyword that fits the 30-character title limit as \`title\`, next best keyword that fits the 30-character subtitle limit as \`subtitle\`, and remaining high-value keywords that fit the 100-character keyword field as \`keyword field\`.
1578
+ `;
1579
+ prompt += `**Ordering rule:** Place the strongest important terms on the left in every field. A word used in \`aso.title\`, \`aso.subtitle\`, or \`aso.keywords\` must not appear in either of the other two fields.
1580
+ `;
1581
+ prompt += `**Phrase rule:** Do not split multi-word keywords across fields. Keep the complete phrase together in one field.
1582
+ `;
1583
+ prompt += `**Cleanup rule:** Use singular forms, remove Apple-ignored stop words (a/and/the/for/with/app/to/etc.), and exclude the company/app name plus inherited category keywords such as "Health & Fitness".
1584
+
1585
+ `;
1586
+ prompt += `**US Store suggestion rule:** For US Store keyword suggestions, find suggestions for this app and only add keywords with popularity >25 and difficulty <75.
1587
+
1512
1588
  `;
1513
1589
  prompt += `### Tier 1 Keywords (Core) \u2192 Title & Subtitle
1514
1590
  `;
1515
1591
  prompt += `- \`aso.title\` (\u226430): **"App Name: [Tier1 Keyword]"** format
1516
1592
  `;
1517
- prompt += ` - App name in English, keyword in target language, uppercase after colon
1593
+ prompt += ` - App name in English, keyword in target language with natural casing
1518
1594
  `;
1519
1595
  prompt += ` - **Do NOT translate/rename the app name**
1520
1596
  `;
1521
- prompt += `- \`aso.subtitle\` (\u226430): Use remaining Tier 1 keywords
1597
+ prompt += `- \`aso.subtitle\` (\u226430): Use remaining Tier 1 keywords without repeating title terms
1522
1598
  `;
1523
1599
  prompt += `- \`aso.shortDescription\` (\u226480): Tier 1 + Tier 2 keywords (no emojis/CAPS)
1524
1600
 
1525
1601
  `;
1526
1602
  prompt += `### Tier 2 Keywords (Feature) \u2192 Keywords Field & Descriptions
1527
1603
  `;
1528
- prompt += `- \`aso.keywords\` (\u2264100): ALL tiers, comma-separated (Tier 1 first, then Tier 2, then Tier 3)
1604
+ prompt += `- \`aso.keywords\` (\u2264100): ALL tiers, comma-separated with commas only and no spaces (Tier 1 first, then Tier 2, then Tier 3); fill as close to 100 chars as possible using relevant keywords only
1529
1605
  `;
1530
1606
  prompt += `- \`landing.hero.title\`: Tier 1 + Tier 2 keywords
1531
1607
  `;
@@ -1566,11 +1642,21 @@ ${researchSections.join("\n")}
1566
1642
  prompt += `## Step 3: Validate (after applying all keywords)
1567
1643
 
1568
1644
  `;
1569
- prompt += `Check all limits using ${FIELD_LIMITS_DOC_PATH2}: title \u226430, subtitle \u226430, shortDescription \u226480, keywords \u2264100, intro \u2264300, outro \u2264200
1645
+ prompt += `Check all limits using ${FIELD_LIMITS_DOC_PATH}: title \u226430, subtitle \u226430, shortDescription \u226480, keywords \u2264100, intro \u2264300, outro \u2264200
1646
+ `;
1647
+ prompt += `- Apply ${ASO_OVERVIEW_DOC_PATH}: keyword popularity \u226520 where available, achievable difficulty, relevance, likely user intent, singular forms, important keywords first
1570
1648
  `;
1571
- prompt += `- Remove keyword duplicates (unique list; avoid repeating title/subtitle terms verbatim)
1649
+ prompt += `- Confirm title/subtitle/keywords priority placement: title gets the most important fitting keyword, subtitle gets the next important fitting non-overlapping keyword, keywords gets remaining non-overlapping terms
1572
1650
  `;
1573
- prompt += `- Ensure App Store/Play Store rules from ${FIELD_LIMITS_DOC_PATH2} are satisfied (no disallowed characters/formatting)
1651
+ prompt += `- Confirm multi-word keywords are not split across fields and stop words/category/company terms were excluded
1652
+ `;
1653
+ prompt += `- For US Store keyword suggestions, confirm every added suggestion has popularity >25 and difficulty <75
1654
+ `;
1655
+ prompt += `- Maximize keyword field utilization: target 90-100/100 chars when enough relevant keywords exist; explain any lower count
1656
+ `;
1657
+ prompt += `- Remove keyword duplicates across all metadata fields: no duplicate terms inside keywords and no overlap between title, subtitle, and keywords; no spaces after commas
1658
+ `;
1659
+ prompt += `- Ensure App Store/Play Store rules from ${FIELD_LIMITS_DOC_PATH} are satisfied (no disallowed characters/formatting)
1574
1660
 
1575
1661
  `;
1576
1662
  prompt += `## Current Data
@@ -1582,11 +1668,15 @@ ${researchSections.join("\n")}
1582
1668
  prompt += `## Output Format
1583
1669
 
1584
1670
  `;
1585
- prompt += `**1. Keyword Research (from saved data)**
1671
+ prompt += `**1. Keyword Research (from provided sources)**
1586
1672
  `;
1587
1673
  prompt += ` - Cite file(s) used and list the selected top 10 keywords (no new research)
1588
1674
  `;
1589
- prompt += ` - Rationale: why these 10 were chosen from saved research
1675
+ prompt += ` - If a Manual Priority Keywords CSV was provided, cite it first, state whether target-country or US fallback rows were used, and explain how those keywords were placed before generated research keywords
1676
+ `;
1677
+ prompt += ` - Tag each selected tracked keyword as \`title\`, \`subtitle\`, or \`keyword field\` according to relevance/current rank and field limits
1678
+ `;
1679
+ prompt += ` - Rationale: why these 10 were chosen from the provided sources
1590
1680
 
1591
1681
  `;
1592
1682
  prompt += `**2. Optimized JSON** (complete ${primaryLocale} locale structure)
@@ -1614,18 +1704,20 @@ ${researchSections.join("\n")}
1614
1704
  `;
1615
1705
  prompt += ` - shortDescription: X/80 \u2713/\u2717
1616
1706
  `;
1617
- prompt += ` - keywords: X/100 \u2713/\u2717 (deduped \u2713/\u2717)
1707
+ prompt += ` - keywords: X/100 \u2713/\u2717 (target 90-100 when possible; deduped \u2713/\u2717; no title/subtitle/keywords overlap \u2713/\u2717)
1708
+ `;
1709
+ prompt += ` - phrase integrity / singular / stop-word / category-name checks: \u2713/\u2717
1618
1710
  `;
1619
1711
  prompt += ` - intro: X/300 \u2713/\u2717
1620
1712
  `;
1621
1713
  prompt += ` - outro: X/200 \u2713/\u2717
1622
1714
  `;
1623
- prompt += ` - Store rules (${FIELD_LIMITS_DOC_PATH2}): \u2713/\u2717
1715
+ prompt += ` - Store rules (${FIELD_LIMITS_DOC_PATH}): \u2713/\u2717
1624
1716
  `;
1625
1717
  prompt += ` - Density: X% (2.5-3%) \u2713/\u2717
1626
1718
 
1627
1719
  `;
1628
- prompt += `**Reference**: ${FIELD_LIMITS_DOC_PATH2}
1720
+ prompt += `**References**: ${ASO_OVERVIEW_DOC_PATH}, ${FIELD_LIMITS_DOC_PATH}
1629
1721
 
1630
1722
  `;
1631
1723
  prompt += `---
@@ -1669,6 +1761,12 @@ function generateKeywordLocalizationPrompt(args) {
1669
1761
  ", "
1670
1762
  )}
1671
1763
 
1764
+ `;
1765
+ prompt += `## ASO Basics
1766
+
1767
+ `;
1768
+ prompt += `- ${ASO_RULES_SUMMARY}
1769
+
1672
1770
  `;
1673
1771
  if (batchIndex !== void 0 && totalBatches !== void 0) {
1674
1772
  prompt += `**\u26A0\uFE0F BATCH PROCESSING MODE**
@@ -1698,7 +1796,7 @@ function generateKeywordLocalizationPrompt(args) {
1698
1796
  `;
1699
1797
  prompt += `2. **Replace ONLY keywords with optimized keywords** - keep ALL existing content, structure, tone, and context unchanged. Only swap keywords for better ASO keywords.
1700
1798
  `;
1701
- prompt += `3. After all keywords are applied, validate character limits + store rules (${FIELD_LIMITS_DOC_PATH2}) + keyword duplication
1799
+ prompt += `3. After all keywords are applied, validate ASO basics (${ASO_OVERVIEW_DOC_PATH}) + character limits/store rules (${FIELD_LIMITS_DOC_PATH}) + keyword duplication
1702
1800
  `;
1703
1801
  prompt += `4. **SAVE the updated JSON to file** using the save-locale-file tool (only if file exists)
1704
1802
 
@@ -1723,7 +1821,7 @@ ${optimizedPrimary}
1723
1821
  prompt += `## Keyword Research (Per Locale)
1724
1822
 
1725
1823
  `;
1726
- prompt += `**Priority:** Use each locale's own keyword research. English fallback is ONLY used when locale-specific research is missing.
1824
+ prompt += `**Priority:** Use product-level Manual Priority Keywords CSV first when present: check target-country Store Domain rows first; if missing, translate/localize US CSV rows for the target locale. Then review each locale's own keyword research alongside the CSV. English fallback research is ONLY used when locale-specific research is missing.
1727
1825
  `;
1728
1826
  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.
1729
1827
 
@@ -1791,6 +1889,18 @@ ${optimizedPrimary}
1791
1889
 
1792
1890
  `;
1793
1891
  prompt += `For EACH locale:
1892
+ `;
1893
+ prompt += `- Source priority: Manual Priority Keywords CSV first (target-country rows, or translated/localized US rows if target-country rows are missing), locale saved keyword research second, translated fallback research third.
1894
+ `;
1895
+ prompt += `- Even when CSV exists, still review the locale saved keyword research and use it to validate choices and fill remaining relevant capacity.
1896
+ `;
1897
+ prompt += `- Metadata placement priority: best fitting keyword by relevance/current rank to title, next best fitting keyword to subtitle, remaining high-value keywords to keyword field.
1898
+ `;
1899
+ prompt += `- Do not repeat the same word across title, subtitle, and keywords. Do not split multi-word keywords across fields.
1900
+ `;
1901
+ prompt += `- Use singular forms, remove Apple-ignored stop words, and exclude company/app/category names.
1902
+ `;
1903
+ prompt += `- For US Store keyword suggestions, find suggestions for this app and only add keywords with popularity >25 and difficulty <75.
1794
1904
  `;
1795
1905
  prompt += `- Priority: Keep iOS-sourced keywords first; add Android keywords only if there is remaining space after iOS keywords fit within field limits.
1796
1906
  `;
@@ -1802,17 +1912,23 @@ ${optimizedPrimary}
1802
1912
  `;
1803
1913
  prompt += ` - App name: **ALWAYS in English** (e.g., "Aurora EOS", "Timeline", "Recaply)
1804
1914
  `;
1805
- prompt += ` - Primary keyword: **In target language** (e.g., "\uC624\uB85C\uB77C \uC608\uBCF4" for Korean, "\u30AA\u30FC\u30ED\u30E9\u4E88\u5831" for Japanese)
1915
+ prompt += ` - Primary keyword: **In target language** (e.g., "aurora forecast" for English, "pronostico de auroras" for Spanish)
1806
1916
  `;
1807
- prompt += ` - Example: "Aurora EOS: \uC624\uB85C\uB77C \uC608\uBCF4" (Korean), "Aurora EOS: \u30AA\u30FC\u30ED\u30E9\u4E88\u5831" (Japanese)
1917
+ prompt += ` - Example: "Aurora EOS: Aurora Forecast" (English), "Aurora EOS: Pronostico de Auroras" (Spanish)
1808
1918
  `;
1809
- prompt += ` - The keyword after the colon must start with an uppercase letter
1919
+ prompt += ` - Use natural casing for the target language
1810
1920
  `;
1811
1921
  prompt += ` - **Do NOT translate/rename the app name**; keep the original English app name across all locales.
1812
1922
  `;
1813
1923
  prompt += ` - **Only replace the keyword part** - keep the app name and format structure unchanged
1814
1924
  `;
1815
- prompt += `4. Deduplicate keywords: final \`aso.keywords\` must be unique and should not repeat title/subtitle terms verbatim
1925
+ prompt += `4. Deduplicate keywords: final \`aso.keywords\` must be unique, comma-only without spaces, as close to 100 chars as possible, and must not repeat any title/subtitle keyword terms
1926
+ `;
1927
+ prompt += ` - Do not split multi-word keywords between title/subtitle/keywords; assign the full phrase to a single field
1928
+ `;
1929
+ prompt += ` - Use singular forms only; remove stop words such as a/and/the/for/with/app/to; exclude app/company/category names
1930
+ `;
1931
+ prompt += ` - For US Store keyword suggestions, only add suggestions with popularity >25 and difficulty <75
1816
1932
  `;
1817
1933
  prompt += `5. **Replace keywords in existing sentences** - swap ONLY the keywords, keep everything else:
1818
1934
  `;
@@ -1854,11 +1970,11 @@ ${optimizedPrimary}
1854
1970
  `;
1855
1971
  prompt += `- Original: "Track aurora with real-time forecasts"
1856
1972
  `;
1857
- prompt += `- Optimized keywords: \uC624\uB85C\uB77C, \uC608\uBCF4, \uC2E4\uC2DC\uAC04
1973
+ prompt += `- Optimized keywords: aurora,forecast,real-time
1858
1974
  `;
1859
- prompt += `- Result: "Track \uC624\uB85C\uB77C with \uC2E4\uC2DC\uAC04 \uC608\uBCF4" (keywords replaced, structure kept)
1975
+ prompt += `- Result: "Track aurora with real-time forecasts" (keywords replaced, structure kept)
1860
1976
  `;
1861
- prompt += ` OR: "\uC2E4\uC2DC\uAC04 \uC608\uBCF4\uB85C \uC624\uB85C\uB77C \uCD94\uC801" (if natural keyword placement requires minor word order, but keep meaning identical)
1977
+ prompt += ` OR: "Track real-time aurora forecasts" (if natural keyword placement requires minor word order, but keep meaning identical)
1862
1978
 
1863
1979
  `;
1864
1980
  prompt += `## Current Translated Locales (This Batch)
@@ -1877,7 +1993,7 @@ ${optimizedPrimary}
1877
1993
  `;
1878
1994
  prompt += `Process EACH locale in this batch sequentially:
1879
1995
  `;
1880
- prompt += `1. Use saved keyword research (in target language) OR **TRANSLATE English keywords from primary locale** if missing (see fallback strategy above - MUST translate, not use English directly)
1996
+ prompt += `1. Use Manual Priority Keywords CSV first when present. If target-country CSV rows are missing and US CSV rows are shown, **TRANSLATE/LOCALIZE those US CSV keywords** to the target locale before applying them. Then use saved keyword research (in target language), OR **TRANSLATE English fallback research from primary locale** if missing (see fallback strategy above - MUST translate, not use English directly)
1881
1997
  `;
1882
1998
  prompt += `2. **Replace keywords ONLY** in ALL fields (keep existing content structure unchanged):
1883
1999
  `;
@@ -1899,7 +2015,9 @@ ${optimizedPrimary}
1899
2015
  `;
1900
2016
  prompt += `3. **CRITICAL**: Ensure ALL landing fields are translated (not English)
1901
2017
  `;
1902
- prompt += `4. After swapping keywords, validate limits + store rules (${FIELD_LIMITS_DOC_PATH2}) + keyword duplication (unique list; avoid repeating title/subtitle terms verbatim)
2018
+ prompt += `4. After swapping keywords, validate ASO basics (${ASO_OVERVIEW_DOC_PATH}) + limits/store rules (${FIELD_LIMITS_DOC_PATH}) + keyword utilization (target 90-100/100 when enough relevant keywords exist) + keyword duplication (unique list; no overlap between title/subtitle/keywords; no spaces after commas)
2019
+ `;
2020
+ prompt += ` - Also validate no multi-word keyword was split across fields, singular forms are used, stop words are removed, and company/app/category names are excluded
1903
2021
  `;
1904
2022
  prompt += `5. **SAVE the updated JSON to file** using save-locale-file tool
1905
2023
  `;
@@ -1940,10 +2058,14 @@ ${optimizedPrimary}
1940
2058
 
1941
2059
  `;
1942
2060
  prompt += `**1. Keyword Source**
2061
+ `;
2062
+ prompt += ` - If Manual Priority Keywords CSV exists: Cite it first; state whether target-country rows or translated US fallback rows were used; list selected CSV keywords and where they were placed
1943
2063
  `;
1944
2064
  prompt += ` - If saved research exists: Cite file(s) used; list selected top 10 keywords (in target language)
1945
2065
  `;
1946
2066
  prompt += ` - If using fallback: List **TRANSLATED** keywords from primary locale (English \u2192 target language) with translation rationale
2067
+ `;
2068
+ prompt += ` - Tag each selected tracked keyword as \`title\`, \`subtitle\`, or \`keyword field\` according to relevance/current rank and field limits
1947
2069
  `;
1948
2070
  prompt += ` - Show final 10 keywords **IN TARGET LANGUAGE** with tier assignments - DO NOT show English keywords
1949
2071
 
@@ -1979,13 +2101,15 @@ ${optimizedPrimary}
1979
2101
  `;
1980
2102
  prompt += ` - shortDescription: X/80 \u2713/\u2717
1981
2103
  `;
1982
- prompt += ` - keywords: X/100 \u2713/\u2717 (deduped \u2713/\u2717; not repeating title/subtitle)
2104
+ prompt += ` - keywords: X/100 \u2713/\u2717 (target 90-100 when possible; deduped \u2713/\u2717; comma-only/no spaces \u2713/\u2717; no title/subtitle/keywords overlap \u2713/\u2717)
2105
+ `;
2106
+ prompt += ` - phrase integrity / singular / stop-word / category-name checks: \u2713/\u2717
1983
2107
  `;
1984
2108
  prompt += ` - intro: X/300 \u2713/\u2717
1985
2109
  `;
1986
2110
  prompt += ` - outro: X/200 \u2713/\u2717
1987
2111
  `;
1988
- prompt += ` - Store rules (${FIELD_LIMITS_DOC_PATH2}): \u2713/\u2717
2112
+ prompt += ` - Store rules (${FIELD_LIMITS_DOC_PATH}): \u2713/\u2717
1989
2113
 
1990
2114
  `;
1991
2115
  prompt += `**4. File Save Confirmation**
@@ -2033,6 +2157,168 @@ validate-aso(slug="${slug}")
2033
2157
  // src/tools/aso/utils/improve/load-keyword-research.util.ts
2034
2158
  import fs6 from "fs";
2035
2159
  import path6 from "path";
2160
+ var LOCALE_STORE_DOMAIN_OVERRIDES = {
2161
+ ar: "sa",
2162
+ en: "us",
2163
+ "en-US": "us",
2164
+ "zh-Hans": "cn",
2165
+ "zh-Hant": "tw"
2166
+ };
2167
+ function parseCsvLine(line) {
2168
+ const values = [];
2169
+ let current = "";
2170
+ let isQuoted = false;
2171
+ for (let index = 0; index < line.length; index += 1) {
2172
+ const char = line[index];
2173
+ const nextChar = line[index + 1];
2174
+ if (char === '"' && isQuoted && nextChar === '"') {
2175
+ current += '"';
2176
+ index += 1;
2177
+ continue;
2178
+ }
2179
+ if (char === '"') {
2180
+ isQuoted = !isQuoted;
2181
+ continue;
2182
+ }
2183
+ if (char === "," && !isQuoted) {
2184
+ values.push(current);
2185
+ current = "";
2186
+ continue;
2187
+ }
2188
+ current += char;
2189
+ }
2190
+ values.push(current);
2191
+ return values;
2192
+ }
2193
+ function getLocaleStoreDomain(locale) {
2194
+ const override = LOCALE_STORE_DOMAIN_OVERRIDES[locale];
2195
+ if (override) {
2196
+ return override;
2197
+ }
2198
+ const localeParts = locale.split("-");
2199
+ return (localeParts[1] || localeParts[0] || "").toLowerCase();
2200
+ }
2201
+ function loadManualKeywordCsvRowsForStoreDomain(slug, storeDomain) {
2202
+ const productResearchDir = path6.join(getKeywordResearchDir(), "products", slug);
2203
+ if (!fs6.existsSync(productResearchDir)) {
2204
+ return [];
2205
+ }
2206
+ const csvFiles = fs6.readdirSync(productResearchDir).filter((file) => file.endsWith(".csv")).sort();
2207
+ if (csvFiles.length === 0) {
2208
+ return [];
2209
+ }
2210
+ const rows = [];
2211
+ for (const file of csvFiles) {
2212
+ const filePath = path6.join(productResearchDir, file);
2213
+ const raw = fs6.readFileSync(filePath, "utf-8").trim();
2214
+ if (!raw) {
2215
+ continue;
2216
+ }
2217
+ const [headerLine, ...dataLines] = raw.split(/\r?\n/);
2218
+ if (!headerLine) {
2219
+ continue;
2220
+ }
2221
+ const headers = parseCsvLine(headerLine).map((header) => header.trim());
2222
+ const getValue = (values, header) => {
2223
+ const index = headers.indexOf(header);
2224
+ return index >= 0 ? values[index]?.trim() || "" : "";
2225
+ };
2226
+ for (const line of dataLines) {
2227
+ if (!line.trim()) {
2228
+ continue;
2229
+ }
2230
+ const values = parseCsvLine(line);
2231
+ const rowStoreDomain = getValue(values, "Store Domain").toLowerCase();
2232
+ if (rowStoreDomain !== storeDomain) {
2233
+ continue;
2234
+ }
2235
+ const keyword = getValue(values, "Keyword");
2236
+ if (!keyword) {
2237
+ continue;
2238
+ }
2239
+ rows.push({
2240
+ filePath,
2241
+ keyword,
2242
+ platform: getValue(values, "Platform"),
2243
+ storeDomain: rowStoreDomain,
2244
+ store: getValue(values, "Store"),
2245
+ ranking: getValue(values, "Ranking"),
2246
+ popularity: getValue(values, "Popularity"),
2247
+ difficulty: getValue(values, "Difficulty"),
2248
+ lastUpdate: getValue(values, "Last Update")
2249
+ });
2250
+ }
2251
+ }
2252
+ const seenKeywords = /* @__PURE__ */ new Set();
2253
+ return rows.filter((row) => {
2254
+ const normalized = row.keyword.toLowerCase();
2255
+ if (seenKeywords.has(normalized)) {
2256
+ return false;
2257
+ }
2258
+ seenKeywords.add(normalized);
2259
+ return true;
2260
+ });
2261
+ }
2262
+ function loadManualKeywordCsv(slug, locale) {
2263
+ const targetStoreDomain = getLocaleStoreDomain(locale);
2264
+ const localeRows = loadManualKeywordCsvRowsForStoreDomain(
2265
+ slug,
2266
+ targetStoreDomain
2267
+ );
2268
+ if (localeRows.length > 0) {
2269
+ return {
2270
+ rows: localeRows,
2271
+ isFallback: false,
2272
+ sourceStoreDomain: targetStoreDomain,
2273
+ targetStoreDomain
2274
+ };
2275
+ }
2276
+ const usRows = targetStoreDomain === "us" ? [] : loadManualKeywordCsvRowsForStoreDomain(slug, "us");
2277
+ return {
2278
+ rows: usRows,
2279
+ isFallback: usRows.length > 0,
2280
+ sourceStoreDomain: "us",
2281
+ targetStoreDomain
2282
+ };
2283
+ }
2284
+ function formatManualKeywordCsvRows(result, locale) {
2285
+ const { rows, isFallback, sourceStoreDomain, targetStoreDomain } = result;
2286
+ const sourceFiles = [...new Set(rows.map((row) => row.filePath))];
2287
+ const lines = [];
2288
+ lines.push(
2289
+ `### Manual Priority Keywords CSV (${locale} / ${targetStoreDomain})`
2290
+ );
2291
+ lines.push(`Source: ${sourceFiles.join(", ")}`);
2292
+ if (isFallback) {
2293
+ lines.push(
2294
+ `Fallback: No CSV rows found for Store Domain "${targetStoreDomain}". Using "${sourceStoreDomain}" CSV keywords as translation source for ${locale}.`
2295
+ );
2296
+ lines.push(
2297
+ "Translation rule: translate/localize these US keywords into the target locale before placing them in title, subtitle, keywords, or landing copy."
2298
+ );
2299
+ }
2300
+ lines.push(
2301
+ "Priority: Apply these CSV keywords before saved keyword research, but still use locale saved research alongside them to validate relevance and fill remaining opportunities."
2302
+ );
2303
+ lines.push(
2304
+ "Placement rule: put the strongest terms in title first, then subtitle, then the keywords field. Do not duplicate the same keyword across title, subtitle, and keywords."
2305
+ );
2306
+ lines.push("");
2307
+ lines.push("**CSV Priority Keywords:**");
2308
+ rows.slice(0, 30).forEach((row, index) => {
2309
+ const metrics = [
2310
+ row.platform ? `platform: ${row.platform}` : "",
2311
+ row.store ? `store: ${row.store}` : "",
2312
+ row.ranking ? `rank: ${row.ranking}` : "",
2313
+ row.popularity ? `popularity: ${row.popularity}` : "",
2314
+ row.difficulty ? `difficulty: ${row.difficulty}` : "",
2315
+ row.lastUpdate ? `updated: ${row.lastUpdate}` : ""
2316
+ ].filter(Boolean);
2317
+ lines.push(`${index + 1}. **${row.keyword}**${metrics.length > 0 ? ` (${metrics.join(", ")})` : ""}`);
2318
+ });
2319
+ lines.push("\n----");
2320
+ return lines.join("\n");
2321
+ }
2036
2322
  function extractRecommended(data) {
2037
2323
  const summary = data?.summary || data?.data?.summary;
2038
2324
  const recommended = summary?.recommendedKeywords;
@@ -2326,9 +2612,15 @@ function loadKeywordResearchForLocale(slug, locale) {
2326
2612
  "locales",
2327
2613
  locale
2328
2614
  );
2615
+ const manualKeywordCsv = loadManualKeywordCsv(slug, locale);
2616
+ const manualKeywordSections = manualKeywordCsv.rows.length > 0 ? [formatManualKeywordCsvRows(manualKeywordCsv, locale)] : [];
2329
2617
  const result = loadKeywordResearchForLocaleInternal(slug, locale);
2330
2618
  if (result) {
2331
- return { ...result, isFallback: false };
2619
+ return {
2620
+ ...result,
2621
+ sections: [...manualKeywordSections, ...result.sections],
2622
+ isFallback: false
2623
+ };
2332
2624
  }
2333
2625
  for (const fallbackLocale of FALLBACK_LOCALES) {
2334
2626
  if (fallbackLocale === locale) continue;
@@ -2344,14 +2636,19 @@ function loadKeywordResearchForLocale(slug, locale) {
2344
2636
  );
2345
2637
  return {
2346
2638
  entries: fallbackResult.entries,
2347
- sections: sectionsWithNotice,
2639
+ sections: [...manualKeywordSections, ...sectionsWithNotice],
2348
2640
  researchDir: fallbackResult.researchDir,
2349
2641
  isFallback: true,
2350
2642
  fallbackLocale
2351
2643
  };
2352
2644
  }
2353
2645
  }
2354
- return { entries: [], sections: [], researchDir, isFallback: false };
2646
+ return {
2647
+ entries: [],
2648
+ sections: manualKeywordSections,
2649
+ researchDir,
2650
+ isFallback: false
2651
+ };
2355
2652
  }
2356
2653
 
2357
2654
  // src/tools/aso/improve-public.ts
@@ -2383,9 +2680,11 @@ var improvePublicTool = {
2383
2680
 
2384
2681
  ## HOW THIS TOOL WORKS
2385
2682
  This tool returns a PROMPT containing:
2683
+ - Product-level manual CSV keyword data from .aso/keywordResearch/products/[slug]/*.csv when present
2386
2684
  - Saved keyword research data (Tier 1/2/3 keywords with traffic/difficulty scores)
2387
2685
  - Current locale data
2388
2686
  - Optimization instructions
2687
+ - ASO basics from ${ASO_OVERVIEW_DOC_PATH} and field limits from ${FIELD_LIMITS_DOC_PATH}
2389
2688
 
2390
2689
  **YOU MUST:**
2391
2690
  1. Read the returned prompt carefully
@@ -2403,9 +2702,11 @@ This tool returns a PROMPT containing:
2403
2702
  - **Stage 2:** Localize to other languages - **each locale uses its OWN keyword research**
2404
2703
 
2405
2704
  ## KEYWORD SOURCES (Per Locale)
2406
- - **Priority 1:** Uses each locale's SAVED keyword research from .aso/keywordResearch/products/[slug]/locales/[locale]/
2407
- - **Priority 2 (Fallback):** If locale-specific research is missing, falls back to en-US/en keywords and TRANSLATES them
2705
+ - **Priority 1:** Uses product-level manual CSV keywords from .aso/keywordResearch/products/[slug]/*.csv. It checks the target locale's country Store Domain first; if missing, it uses US CSV keywords as a translation/localization source.
2706
+ - **Priority 2:** Uses each locale's SAVED keyword research from .aso/keywordResearch/products/[slug]/locales/[locale]/ alongside the CSV to validate relevance and fill remaining opportunities.
2707
+ - **Priority 3 (Fallback):** If locale-specific saved research is missing, falls back to en-US/en saved research and TRANSLATES it
2408
2708
  - iOS and Android research are automatically combined per locale (iOS prioritized)
2709
+ - Title, subtitle, and keywords must be filled in that importance order, with no repeated keyword terms across the three fields
2409
2710
 
2410
2711
  **CRITICAL:** Only processes existing locale files. Does NOT create new files.`,
2411
2712
  inputSchema: inputSchema3
@@ -2575,8 +2876,9 @@ var validateAsoTool = {
2575
2876
  - 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}
2576
2877
  - Google Play: title \u2264${GOOGLE_PLAY_LIMITS.title}, shortDescription \u2264${GOOGLE_PLAY_LIMITS.shortDescription}, fullDescription \u2264${GOOGLE_PLAY_LIMITS.fullDescription}
2577
2878
 
2578
- 2. **Keyword Duplicates** (App Store only):
2579
- - Checks for duplicate keywords in comma-separated list
2879
+ 2. **Keyword Rules** (App Store only):
2880
+ - Checks duplicate keywords, comma-only/no-space formatting, and title/subtitle repetition
2881
+ - Strategy reference: ${ASO_OVERVIEW_DOC_PATH}
2580
2882
 
2581
2883
  3. **Invalid Characters**:
2582
2884
  - Control characters, BOM, zero-width/invisible characters, variation selectors
@@ -2699,10 +3001,26 @@ async function handleValidateAso(input) {
2699
3001
  const keywordIssues = validateKeywords(dataToValidate);
2700
3002
  const filteredKeywordIssues = locale ? keywordIssues.filter((issue) => issue.locale === locale) : keywordIssues;
2701
3003
  if (filteredKeywordIssues.length > 0) {
2702
- results.push(`## Keyword Duplicates
3004
+ results.push(`## Keyword Rule Violations
2703
3005
  `);
2704
3006
  for (const issue of filteredKeywordIssues) {
2705
- results.push(`- [${issue.locale}]: ${issue.duplicates.join(", ")}`);
3007
+ if (issue.duplicates.length > 0) {
3008
+ results.push(
3009
+ `- [${issue.locale}] duplicates: ${issue.duplicates.join(", ")}`
3010
+ );
3011
+ }
3012
+ if (issue.formatting.length > 0) {
3013
+ results.push(
3014
+ `- [${issue.locale}] formatting: ${issue.formatting.join(", ")}`
3015
+ );
3016
+ }
3017
+ if (issue.repeatedFromTitleOrSubtitle.length > 0) {
3018
+ results.push(
3019
+ `- [${issue.locale}] repeats title/subtitle: ${issue.repeatedFromTitleOrSubtitle.join(
3020
+ ", "
3021
+ )}`
3022
+ );
3023
+ }
2706
3024
  }
2707
3025
  results.push("");
2708
3026
  }
@@ -2717,7 +3035,7 @@ async function handleValidateAso(input) {
2717
3035
  `\u274C **Validation failed** - Fix the issues above before pushing to stores.`
2718
3036
  );
2719
3037
  results.push(`
2720
- Reference: ${FIELD_LIMITS_DOC_PATH}`);
3038
+ References: ${ASO_OVERVIEW_DOC_PATH}, ${FIELD_LIMITS_DOC_PATH}`);
2721
3039
  } else if (hasSanitizeWarnings && !fix) {
2722
3040
  results.push(
2723
3041
  `\u26A0\uFE0F **Invalid characters detected** - Run with \`fix: true\` to auto-remove.`
@@ -0,0 +1,418 @@
1
+ import {
2
+ DEFAULT_LOCALE,
3
+ isAppStoreLocale,
4
+ isGooglePlayLocale,
5
+ isSupportedLocale
6
+ } from "./chunk-BOWRBVVV.js";
7
+
8
+ // src/utils/config.util.ts
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import os from "os";
12
+ function getAsoDataDir() {
13
+ const configPath = path.join(
14
+ os.homedir(),
15
+ ".config",
16
+ "pabal-mcp",
17
+ "config.json"
18
+ );
19
+ if (!fs.existsSync(configPath)) {
20
+ throw new Error(
21
+ `Config file not found at ${configPath}. Please create the config file and set the 'dataDir' property to specify the ASO data directory.`
22
+ );
23
+ }
24
+ try {
25
+ const configContent = fs.readFileSync(configPath, "utf-8");
26
+ const config = JSON.parse(configContent);
27
+ if (!config.dataDir) {
28
+ throw new Error(
29
+ `'dataDir' property is not set in ${configPath}. Please set 'dataDir' to specify the ASO data directory.`
30
+ );
31
+ }
32
+ if (path.isAbsolute(config.dataDir)) {
33
+ return config.dataDir;
34
+ }
35
+ return path.resolve(os.homedir(), config.dataDir);
36
+ } catch (error) {
37
+ if (error instanceof Error && error.message.includes("dataDir")) {
38
+ throw error;
39
+ }
40
+ throw new Error(
41
+ `Failed to read config from ${configPath}: ${error instanceof Error ? error.message : String(error)}`
42
+ );
43
+ }
44
+ }
45
+ function getPullDataDir() {
46
+ return path.join(getAsoDataDir(), ".aso", "pullData");
47
+ }
48
+ function getPushDataDir() {
49
+ return path.join(getAsoDataDir(), ".aso", "pushData");
50
+ }
51
+ function getPublicDir() {
52
+ return path.join(getAsoDataDir(), "public");
53
+ }
54
+ function getKeywordResearchDir() {
55
+ return path.join(getAsoDataDir(), ".aso", "keywordResearch");
56
+ }
57
+ function getProductsDir() {
58
+ return path.join(getPublicDir(), "products");
59
+ }
60
+ function loadConfig() {
61
+ const configPath = path.join(
62
+ os.homedir(),
63
+ ".config",
64
+ "pabal-mcp",
65
+ "config.json"
66
+ );
67
+ if (!fs.existsSync(configPath)) {
68
+ return {};
69
+ }
70
+ try {
71
+ const configContent = fs.readFileSync(configPath, "utf-8");
72
+ return JSON.parse(configContent);
73
+ } catch {
74
+ return {};
75
+ }
76
+ }
77
+ function getGeminiApiKey() {
78
+ const config = loadConfig();
79
+ if (config.gemini?.apiKey) {
80
+ return config.gemini.apiKey;
81
+ }
82
+ const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
83
+ if (envKey) {
84
+ return envKey;
85
+ }
86
+ throw new Error(
87
+ `Gemini API key not found. Set it in ~/.config/pabal-mcp/config.json under "gemini.apiKey" or use GEMINI_API_KEY environment variable.`
88
+ );
89
+ }
90
+
91
+ // src/utils/aso-converter.ts
92
+ import fs2 from "fs";
93
+ import path2 from "path";
94
+ function generateFullDescription(localeData, metadata = {}) {
95
+ const { aso, landing } = localeData;
96
+ const template = aso?.template;
97
+ if (!template) {
98
+ return "";
99
+ }
100
+ const landingFeatures = landing?.features?.items || [];
101
+ const landingScreenshots = landing?.screenshots?.images || [];
102
+ const keyHeading = template.keyFeaturesHeading || "Key Features";
103
+ const featuresHeading = template.featuresHeading || "Additional Features";
104
+ const parts = [template.intro];
105
+ if (landingFeatures.length > 0) {
106
+ parts.push(
107
+ "",
108
+ keyHeading,
109
+ "",
110
+ ...landingFeatures.map(
111
+ (feature) => [`\u25B6\uFE0E ${feature.title}`, feature.body || ""].filter(Boolean).join("\n")
112
+ )
113
+ );
114
+ }
115
+ if (landingScreenshots.length > 0) {
116
+ parts.push("", featuresHeading, "");
117
+ parts.push(
118
+ ...landingScreenshots.map(
119
+ (screenshot) => [`\u25B6\uFE0E ${screenshot.title}`, screenshot.description || ""].filter(Boolean).join("\n")
120
+ )
121
+ );
122
+ }
123
+ parts.push("", template.outro);
124
+ const includeSupport = template.includeSupportLinks ?? true;
125
+ if (includeSupport) {
126
+ const contactLines = [
127
+ metadata.instagram ? `Instagram: ${metadata.instagram}` : null,
128
+ metadata.contactEmail ? `Email: ${metadata.contactEmail}` : null,
129
+ metadata.termsUrl ? `- Terms of Use: ${metadata.termsUrl}` : null,
130
+ metadata.privacyUrl ? `- Privacy Policy: ${metadata.privacyUrl}` : null
131
+ ].filter((line) => line !== null);
132
+ if (contactLines.length > 0) {
133
+ parts.push("", "[Contact & Support]", "", ...contactLines);
134
+ }
135
+ }
136
+ return parts.join("\n");
137
+ }
138
+ function loadAsoFromConfig(slug) {
139
+ const productsDir = getProductsDir();
140
+ const configPath = path2.join(productsDir, slug, "config.json");
141
+ console.debug(`[loadAsoFromConfig] Looking for ${slug}:`);
142
+ console.debug(` - productsDir: ${productsDir}`);
143
+ console.debug(` - configPath: ${configPath}`);
144
+ console.debug(` - configPath exists: ${fs2.existsSync(configPath)}`);
145
+ if (!fs2.existsSync(configPath)) {
146
+ console.warn(`[loadAsoFromConfig] Config file not found at ${configPath}`);
147
+ return {};
148
+ }
149
+ try {
150
+ const configContent = fs2.readFileSync(configPath, "utf-8");
151
+ const config = JSON.parse(configContent);
152
+ const localesDir = path2.join(productsDir, slug, "locales");
153
+ console.debug(` - localesDir: ${localesDir}`);
154
+ console.debug(` - localesDir exists: ${fs2.existsSync(localesDir)}`);
155
+ if (!fs2.existsSync(localesDir)) {
156
+ console.warn(
157
+ `[loadAsoFromConfig] Locales directory not found at ${localesDir}`
158
+ );
159
+ return {};
160
+ }
161
+ const localeFiles = fs2.readdirSync(localesDir).filter((f) => f.endsWith(".json"));
162
+ const locales = {};
163
+ for (const file of localeFiles) {
164
+ const localeCode = file.replace(".json", "");
165
+ const localePath = path2.join(localesDir, file);
166
+ const localeContent = fs2.readFileSync(localePath, "utf-8");
167
+ locales[localeCode] = JSON.parse(localeContent);
168
+ }
169
+ console.debug(
170
+ ` - Found ${Object.keys(locales).length} locale file(s): ${Object.keys(
171
+ locales
172
+ ).join(", ")}`
173
+ );
174
+ if (Object.keys(locales).length === 0) {
175
+ console.warn(
176
+ `[loadAsoFromConfig] No locale files found in ${localesDir}`
177
+ );
178
+ }
179
+ const defaultLocale = config.content?.defaultLocale || DEFAULT_LOCALE;
180
+ const asoData = {};
181
+ if (config.packageName) {
182
+ const googlePlayLocales = {};
183
+ const metadata = config.metadata || {};
184
+ const screenshots = metadata.screenshots || {};
185
+ for (const [locale, localeData] of Object.entries(locales)) {
186
+ if (!isSupportedLocale(locale)) {
187
+ console.debug(
188
+ `Skipping locale ${locale} - not a valid unified locale`
189
+ );
190
+ continue;
191
+ }
192
+ if (!isGooglePlayLocale(locale)) {
193
+ console.debug(
194
+ `Skipping locale ${locale} - not supported by Google Play`
195
+ );
196
+ continue;
197
+ }
198
+ const aso = localeData.aso || {};
199
+ if (!aso || !aso.title && !aso.shortDescription) {
200
+ console.warn(
201
+ `Locale ${locale} has no ASO data (title or shortDescription)`
202
+ );
203
+ }
204
+ const screenshotsDir = path2.join(
205
+ productsDir,
206
+ slug,
207
+ "screenshots",
208
+ locale
209
+ );
210
+ const phoneDir = path2.join(screenshotsDir, "phone");
211
+ const tabletDir = path2.join(screenshotsDir, "tablet");
212
+ const featureGraphicPath = path2.join(
213
+ screenshotsDir,
214
+ "feature-graphic.png"
215
+ );
216
+ const hasPhoneScreenshots = fs2.existsSync(phoneDir);
217
+ const hasTabletScreenshots = fs2.existsSync(tabletDir);
218
+ const hasFeatureGraphic = fs2.existsSync(featureGraphicPath);
219
+ const localeScreenshots = {
220
+ phone: hasPhoneScreenshots ? screenshots.phone?.map(
221
+ (p) => p.replace(/\/screenshots\/[^/]+\//, `/screenshots/${locale}/`)
222
+ ) : void 0,
223
+ tablet: hasTabletScreenshots ? screenshots.tablet?.map(
224
+ (p) => p.replace(/\/screenshots\/[^/]+\//, `/screenshots/${locale}/`)
225
+ ) : void 0
226
+ };
227
+ googlePlayLocales[locale] = {
228
+ title: aso.title || "",
229
+ shortDescription: aso.shortDescription || "",
230
+ fullDescription: generateFullDescription(localeData, metadata),
231
+ packageName: config.packageName,
232
+ defaultLanguage: locale,
233
+ screenshots: {
234
+ phone: localeScreenshots.phone || [],
235
+ tablet: localeScreenshots.tablet
236
+ },
237
+ featureGraphic: hasFeatureGraphic ? `/products/${slug}/screenshots/${locale}/feature-graphic.png` : metadata.featureGraphic
238
+ };
239
+ }
240
+ const googleLocaleKeys = Object.keys(googlePlayLocales);
241
+ if (googleLocaleKeys.length > 0) {
242
+ const hasConfigDefault = isGooglePlayLocale(defaultLocale) && Boolean(googlePlayLocales[defaultLocale]);
243
+ const resolvedDefault = hasConfigDefault ? defaultLocale : googlePlayLocales[DEFAULT_LOCALE] ? DEFAULT_LOCALE : googleLocaleKeys[0];
244
+ asoData.googlePlay = {
245
+ locales: googlePlayLocales,
246
+ defaultLocale: resolvedDefault,
247
+ // App-level contact information
248
+ contactEmail: metadata.contactEmail,
249
+ contactWebsite: metadata.supportUrl,
250
+ youtubeUrl: metadata.youtubeUrl
251
+ };
252
+ }
253
+ }
254
+ if (config.bundleId) {
255
+ const appStoreLocales = {};
256
+ const metadata = config.metadata || {};
257
+ const screenshots = metadata.screenshots || {};
258
+ for (const [locale, localeData] of Object.entries(locales)) {
259
+ if (!isSupportedLocale(locale)) {
260
+ console.debug(
261
+ `Skipping locale ${locale} - not a valid unified locale`
262
+ );
263
+ continue;
264
+ }
265
+ if (!isAppStoreLocale(locale)) {
266
+ console.debug(
267
+ `Skipping locale ${locale} - not supported by App Store`
268
+ );
269
+ continue;
270
+ }
271
+ const aso = localeData.aso || {};
272
+ if (!aso || !aso.title && !aso.shortDescription) {
273
+ console.warn(
274
+ `Locale ${locale} has no ASO data (title or shortDescription)`
275
+ );
276
+ }
277
+ const screenshotsDir = path2.join(
278
+ productsDir,
279
+ slug,
280
+ "screenshots",
281
+ locale
282
+ );
283
+ const phoneDir = path2.join(screenshotsDir, "phone");
284
+ const tabletDir = path2.join(screenshotsDir, "tablet");
285
+ const hasPhoneScreenshots = fs2.existsSync(phoneDir);
286
+ const hasTabletScreenshots = fs2.existsSync(tabletDir);
287
+ const localeScreenshots = {
288
+ phone: hasPhoneScreenshots ? screenshots.phone?.map(
289
+ (p) => p.replace(/\/screenshots\/[^/]+\//, `/screenshots/${locale}/`)
290
+ ) : void 0,
291
+ tablet: hasTabletScreenshots ? screenshots.tablet?.map(
292
+ (p) => p.replace(/\/screenshots\/[^/]+\//, `/screenshots/${locale}/`)
293
+ ) : void 0
294
+ };
295
+ appStoreLocales[locale] = {
296
+ name: aso.title || "",
297
+ subtitle: aso.subtitle,
298
+ description: generateFullDescription(localeData, metadata),
299
+ keywords: Array.isArray(aso.keywords) ? aso.keywords.join(",") : aso.keywords,
300
+ promotionalText: void 0,
301
+ bundleId: config.bundleId,
302
+ locale,
303
+ screenshots: {
304
+ // Map phone screenshots to iphone65
305
+ iphone65: localeScreenshots.phone || [],
306
+ // Map tablet screenshots to ipadPro129
307
+ ipadPro129: localeScreenshots.tablet
308
+ }
309
+ };
310
+ }
311
+ const appStoreLocaleKeys = Object.keys(appStoreLocales);
312
+ if (appStoreLocaleKeys.length > 0) {
313
+ const hasConfigDefault = isAppStoreLocale(defaultLocale) && Boolean(appStoreLocales[defaultLocale]);
314
+ const resolvedDefault = hasConfigDefault ? defaultLocale : appStoreLocales[DEFAULT_LOCALE] ? DEFAULT_LOCALE : appStoreLocaleKeys[0];
315
+ asoData.appStore = {
316
+ locales: appStoreLocales,
317
+ defaultLocale: resolvedDefault,
318
+ // App-level contact information
319
+ contactEmail: metadata.contactEmail,
320
+ supportUrl: metadata.supportUrl,
321
+ marketingUrl: metadata.marketingUrl,
322
+ privacyPolicyUrl: metadata.privacyUrl,
323
+ termsUrl: metadata.termsUrl
324
+ };
325
+ }
326
+ }
327
+ const hasGooglePlay = !!asoData.googlePlay;
328
+ const hasAppStore = !!asoData.appStore;
329
+ console.debug(`[loadAsoFromConfig] Result for ${slug}:`);
330
+ console.debug(
331
+ ` - Google Play data: ${hasGooglePlay ? "found" : "not found"}`
332
+ );
333
+ console.debug(` - App Store data: ${hasAppStore ? "found" : "not found"}`);
334
+ if (!hasGooglePlay && !hasAppStore) {
335
+ console.warn(`[loadAsoFromConfig] No ASO data generated for ${slug}`);
336
+ }
337
+ return asoData;
338
+ } catch (error) {
339
+ console.error(
340
+ `[loadAsoFromConfig] Failed to load ASO data from config for ${slug}:`,
341
+ error
342
+ );
343
+ return {};
344
+ }
345
+ }
346
+ function saveAsoToConfig(slug, config) {
347
+ const productsDir = getProductsDir();
348
+ const configPath = path2.join(productsDir, slug, "config.json");
349
+ fs2.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
350
+ }
351
+ function saveAsoToAsoDir(slug, asoData) {
352
+ const rootDir = getPushDataDir();
353
+ if (asoData.googlePlay) {
354
+ const asoPath = path2.join(
355
+ rootDir,
356
+ "products",
357
+ slug,
358
+ "store",
359
+ "google-play",
360
+ "aso-data.json"
361
+ );
362
+ const dir = path2.dirname(asoPath);
363
+ if (!fs2.existsSync(dir)) {
364
+ fs2.mkdirSync(dir, { recursive: true });
365
+ }
366
+ const googlePlayData = asoData.googlePlay;
367
+ const multilingualData = "locales" in googlePlayData ? googlePlayData : {
368
+ locales: {
369
+ [googlePlayData.defaultLanguage || DEFAULT_LOCALE]: googlePlayData
370
+ },
371
+ defaultLocale: googlePlayData.defaultLanguage || DEFAULT_LOCALE
372
+ };
373
+ fs2.writeFileSync(
374
+ asoPath,
375
+ JSON.stringify({ googlePlay: multilingualData }, null, 2) + "\n",
376
+ "utf-8"
377
+ );
378
+ }
379
+ if (asoData.appStore) {
380
+ const asoPath = path2.join(
381
+ rootDir,
382
+ "products",
383
+ slug,
384
+ "store",
385
+ "app-store",
386
+ "aso-data.json"
387
+ );
388
+ const dir = path2.dirname(asoPath);
389
+ if (!fs2.existsSync(dir)) {
390
+ fs2.mkdirSync(dir, { recursive: true });
391
+ }
392
+ const appStoreData = asoData.appStore;
393
+ const multilingualData = "locales" in appStoreData ? appStoreData : {
394
+ locales: {
395
+ [appStoreData.locale || DEFAULT_LOCALE]: appStoreData
396
+ },
397
+ defaultLocale: appStoreData.locale || DEFAULT_LOCALE
398
+ };
399
+ fs2.writeFileSync(
400
+ asoPath,
401
+ JSON.stringify({ appStore: multilingualData }, null, 2) + "\n",
402
+ "utf-8"
403
+ );
404
+ }
405
+ }
406
+
407
+ export {
408
+ getAsoDataDir,
409
+ getPullDataDir,
410
+ getPushDataDir,
411
+ getPublicDir,
412
+ getKeywordResearchDir,
413
+ getProductsDir,
414
+ getGeminiApiKey,
415
+ loadAsoFromConfig,
416
+ saveAsoToConfig,
417
+ saveAsoToAsoDir
418
+ };
package/dist/index.d.ts CHANGED
@@ -6,20 +6,20 @@ import 'appstore-connect-sdk/openapi';
6
6
  /**
7
7
  * ASO Data Converter
8
8
  *
9
- * config.json (source of truth) aso-data.json (build artifact) 변환 유틸리티
9
+ * Converts between config.json (source of truth) and aso-data.json (build artifact).
10
10
  */
11
11
 
12
12
  /**
13
- * config.json에서 ASO 데이터를 읽어옵니다
14
- * 구조: config.json (메타데이터) + locales/{locale}.json (콘텐츠)
13
+ * Read ASO data from config.json.
14
+ * New structure: config.json (metadata) + locales/{locale}.json (content)
15
15
  */
16
16
  declare function loadAsoFromConfig(slug: string): AsoData;
17
17
  /**
18
- * config.json에 ASO 데이터를 저장합니다
18
+ * Save ASO data to config.json.
19
19
  */
20
20
  declare function saveAsoToConfig(slug: string, config: ProductConfig): void;
21
21
  /**
22
- * ASO 데이터를 지정한 ASO 디렉토리에 저장합니다
22
+ * Save ASO data to the configured ASO directory.
23
23
  */
24
24
  declare function saveAsoToAsoDir(slug: string, asoData: AsoData): void;
25
25
 
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  loadAsoFromConfig,
10
10
  saveAsoToAsoDir,
11
11
  saveAsoToConfig
12
- } from "./chunk-4JFBWKY4.js";
12
+ } from "./chunk-H4MYWLFK.js";
13
13
  import {
14
14
  APP_STORE_TO_UNIFIED,
15
15
  DEFAULT_LOCALE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pabal-resource-mcp",
3
- "version": "1.10.5",
3
+ "version": "1.10.7",
4
4
  "type": "module",
5
5
  "description": "MCP server for ASO data management with shared types and utilities",
6
6
  "author": "skyu",