pabal-web-mcp 1.2.4 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,142 +2,58 @@
2
2
 
3
3
  MCP (Model Context Protocol) server for bidirectional conversion between ASO (App Store Optimization) and web SEO data.
4
4
 
5
- This library enables seamless reuse of ASO data for web SEO purposes, allowing you to convert ASO metadata directly into web SEO content and vice versa.
5
+ This library enables seamless reuse of ASO data for web SEO purposes, allowing you to convert ASO metadata directly into web SEO content and vice versa. **Build your own synced website** based on ASO data from App Store Connect and Google Play Console, keeping your app store listings and web presence perfectly synchronized.
6
+
7
+ > 💡 **Example**: Check out [labs.quartz.best](https://labs.quartz.best/) to see a live website built with this library, where app store data is automatically synced to create a beautiful, SEO-optimized web presence.
6
8
 
7
9
  [![한국어 docs](https://img.shields.io/badge/docs-Korean-green)](./i18n/README.ko.md)
8
10
 
9
- ## 🛠️ MCP Client Installation
11
+ ## 🛠️ Installation
10
12
 
11
13
  ### Requirements
12
14
 
13
15
  - Node.js >= 18
14
- - MCP client: Cursor, Claude Code, VS Code, Windsurf, etc.
15
-
16
- > [!TIP]
17
- > If you repeatedly do ASO/store tasks, add a client rule like "always use pabal-web-mcp" so the MCP server auto-invokes without typing it every time.
18
-
19
- <details>
20
- <summary><b>Install in Cursor</b></summary>
21
-
22
- Add to `~/.cursor/mcp.json` (global) or project `.cursor/mcp.json`:
23
-
24
- ```json
25
- {
26
- "mcpServers": {
27
- "pabal-web-mcp": {
28
- "command": "npx",
29
- "args": ["-y", "pabal-web-mcp"]
30
- }
31
- }
32
- }
33
- ```
34
-
35
- Or if installed globally:
36
-
37
- ```json
38
- {
39
- "mcpServers": {
40
- "pabal-web-mcp": {
41
- "command": "pabal-web-mcp"
42
- }
43
- }
44
- }
45
- ```
46
-
47
- </details>
48
-
49
- <details>
50
- <summary><b>Install in VS Code</b></summary>
51
-
52
- Example `settings.json` MCP section:
53
-
54
- ```json
55
- "mcp": {
56
- "servers": {
57
- "pabal-web-mcp": {
58
- "type": "stdio",
59
- "command": "npx",
60
- "args": ["-y", "pabal-web-mcp"]
61
- }
62
- }
63
- }
64
- ```
65
-
66
- Or if installed globally:
67
-
68
- ```json
69
- "mcp": {
70
- "servers": {
71
- "pabal-web-mcp": {
72
- "type": "stdio",
73
- "command": "pabal-web-mcp"
74
- }
75
- }
76
- }
77
- ```
78
-
79
- </details>
80
-
81
- <details>
82
- <summary><b>Install in Claude Code</b></summary>
16
+ - [pabal-mcp](https://github.com/quartz-labs-dev/pabal-mcp) must be installed and configured
83
17
 
84
- > [!TIP]
85
- > See the [official Claude Code MCP documentation](https://code.claude.com/docs/en/mcp#setting-up-enterprise-mcp-configuration) for detailed configuration options.
18
+ ### Install as Library
86
19
 
87
- Add to Claude Code MCP settings (JSON format):
20
+ Install this library in your website project:
88
21
 
89
- ```json
90
- {
91
- "mcpServers": {
92
- "pabal-web-mcp": {
93
- "command": "npx",
94
- "args": ["-y", "pabal-web-mcp"]
95
- }
96
- }
97
- }
22
+ ```bash
23
+ npm install pabal-web-mcp
24
+ # or
25
+ yarn add pabal-web-mcp
26
+ # or
27
+ pnpm add pabal-web-mcp
98
28
  ```
99
29
 
100
- Or if installed globally (`npm install -g pabal-web-mcp`):
30
+ ## 🔐 Configure Credentials
101
31
 
102
- ```json
103
- {
104
- "mcpServers": {
105
- "pabal-web-mcp": {
106
- "command": "pabal-web-mcp"
107
- }
108
- }
109
- }
110
- ```
32
+ pabal-web-mcp uses the configuration file from `pabal-mcp`. For detailed credential setup instructions (App Store Connect API keys, Google Play service accounts, etc.), please refer to the [pabal-mcp README](https://github.com/quartz-labs-dev/pabal-mcp?tab=readme-ov-file#-configure-credentials).
111
33
 
112
- </details>
34
+ ### ⚠️ Important: Set dataDir Path
113
35
 
114
- <details>
115
- <summary><b>Install in Windsurf</b></summary>
36
+ **You must set `dataDir` in `~/.config/pabal-mcp/config.json` to the absolute path where your `pabal-web` project is stored on your local machine.**
116
37
 
117
38
  ```json
118
39
  {
119
- "mcpServers": {
120
- "pabal-web-mcp": {
121
- "command": "npx",
122
- "args": ["-y", "pabal-web-mcp"]
123
- }
40
+ "dataDir": "/ABSOLUTE/PATH/TO/pabal-web",
41
+ "appStore": {
42
+ "issuerId": "xxxx",
43
+ "keyId": "xxxx",
44
+ "privateKeyPath": "./app-store-key.p8"
45
+ },
46
+ "googlePlay": {
47
+ "serviceAccountKeyPath": "./google-play-service-account.json"
124
48
  }
125
49
  }
126
50
  ```
127
51
 
128
- Or if installed globally:
52
+ Examples:
129
53
 
130
- ```json
131
- {
132
- "mcpServers": {
133
- "pabal-web-mcp": {
134
- "command": "pabal-web-mcp"
135
- }
136
- }
137
- }
138
- ```
139
-
140
- </details>
54
+ - macOS: `"/Users/username/projects/pabal-web"`
55
+ - Linux: `"/home/username/projects/pabal-web"`
56
+ - Windows: `"C:\\Users\\username\\projects\\pabal-web"`
141
57
 
142
58
  ## MCP Server
143
59
 
@@ -145,87 +61,34 @@ This package includes an MCP server for managing ASO data through Claude or othe
145
61
 
146
62
  ### Available Tools
147
63
 
148
- | Tool | Description |
149
- | ---------------- | -------------------------------------------------- |
150
- | `aso-to-public` | Convert ASO data to public config format |
151
- | `public-to-aso` | Convert public config to ASO data format |
152
- | `improve-public` | Improve product locale content with AI suggestions |
153
- | `init-project` | Initialize a new product project structure |
154
-
155
- ## Usage
156
-
157
- ### Importing Types
158
-
159
- ```typescript
160
- import type {
161
- // ASO Types
162
- AsoData,
163
- AppStoreAsoData,
164
- GooglePlayAsoData,
165
-
166
- // Product Types
167
- ProductConfig,
168
- ProductLocale,
169
- LandingPage,
170
- LandingHero,
171
- LandingScreenshots,
172
- LandingFeatures,
173
- LandingReviews,
174
- LandingCta,
175
- } from "pabal-web-mcp";
176
- ```
177
-
178
- ### Importing Utilities
179
-
180
- ```typescript
181
- import {
182
- // ASO Converter
183
- loadAsoFromConfig,
184
-
185
- // Locale Constants
186
- DEFAULT_LOCALE,
187
- UNIFIED_LOCALES,
188
-
189
- // Locale Converters
190
- unifiedToAppStore,
191
- unifiedToGooglePlay,
192
- appStoreToUnified,
193
- googlePlayToUnified,
194
- } from "pabal-web-mcp";
195
- ```
196
-
197
- ### Example: Loading ASO Data
198
-
199
- ```typescript
200
- import { loadAsoFromConfig } from "pabal-web-mcp";
201
-
202
- const asoData = loadAsoFromConfig("my-app");
203
- console.log(asoData.appStore?.name);
204
- console.log(asoData.googlePlay?.title);
205
- ```
206
-
207
- ## Types Reference
208
-
209
- ### ASO Types
210
-
211
- - `AsoData` - Combined ASO data for both stores
212
- - `AppStoreAsoData` - App Store specific ASO data
213
- - `GooglePlayAsoData` - Google Play specific ASO data
214
- - `AppStoreMultilingualAsoData` - Multilingual App Store data
215
- - `GooglePlayMultilingualAsoData` - Multilingual Google Play data
216
-
217
- ### Product Types
218
-
219
- - `ProductConfig` - Product configuration
220
- - `ProductLocale` - Localized product content
221
- - `LandingPage` - Landing page structure
222
- - `AppPageData` - Complete app page data
223
-
224
- ### Locale Types
225
-
226
- - `UnifiedLocale` - Unified locale code (e.g., "en-US", "ko-KR")
227
-
228
- ## Supported Locales
64
+ | Tool | Description |
65
+ | ------------------ | -------------------------------------------------------- |
66
+ | `aso-to-public` | Convert ASO data to public config format |
67
+ | `public-to-aso` | Convert public config to ASO data format |
68
+ | `keyword-research` | Plan/persist ASO keyword research (.aso/keywordResearch) |
69
+ | `improve-public` | Improve product locale content with AI suggestions |
70
+ | `init-project` | Initialize a new product project structure |
71
+ | `create-blog-html` | Generate static HTML blog posts with BLOG_META headers |
72
+
73
+ ### Using external keyword MCP ([appreply-co/mcp-appstore](https://github.com/appreply-co/mcp-appstore))
74
+
75
+ 1. Install deps in the existing clone: `cd external-tools/mcp-appstore && npm install`
76
+ 2. Run server: `node server.js` (same cwd; `npm start` also works). If your MCP client allows, let the LLM start this process before keyword research and stop it after; otherwise start/stop it manually.
77
+ 3. Register in your MCP client (example):
78
+ ```json
79
+ {
80
+ "mcpServers": {
81
+ "mcp-appstore": {
82
+ "command": "node",
83
+ "args": ["/ABSOLUTE/PATH/TO/pabal-web-mcp/external-tools/mcp-appstore/server.js"],
84
+ "cwd": "/ABSOLUTE/PATH/TO/pabal-web-mcp/external-tools/mcp-appstore"
85
+ }
86
+ }
87
+ }
88
+ ```
89
+ 4. Use it with `keyword-research` (saves to `.aso/keywordResearch/...`) before `improve-public` to supply keyword data.
90
+
91
+ ### Supported Locales
229
92
 
230
93
  Supports all languages supported by each store.
231
94
 
@@ -3,13 +3,14 @@ import {
3
3
  DEFAULT_APP_SLUG
4
4
  } from "../chunk-DLCIXAUB.js";
5
5
  import {
6
+ getKeywordResearchDir,
6
7
  getProductsDir,
7
8
  getPublicDir,
8
9
  getPullDataDir,
9
10
  getPushDataDir,
10
11
  loadAsoFromConfig,
11
12
  saveAsoToAsoDir
12
- } from "../chunk-FXCHLO7O.js";
13
+ } from "../chunk-W62HB2ZL.js";
13
14
  import {
14
15
  DEFAULT_LOCALE,
15
16
  appStoreToUnified,
@@ -1194,7 +1195,14 @@ ${json}
1194
1195
  // src/tools/utils/improve-public/generate-aso-prompt.util.ts
1195
1196
  var FIELD_LIMITS_DOC_PATH2 = "docs/aso/ASO_FIELD_LIMITS.md";
1196
1197
  function generatePrimaryOptimizationPrompt(args) {
1197
- const { slug, category, primaryLocale, localeSections } = args;
1198
+ const {
1199
+ slug,
1200
+ category,
1201
+ primaryLocale,
1202
+ localeSections,
1203
+ keywordResearchByLocale,
1204
+ keywordResearchDirByLocale
1205
+ } = args;
1198
1206
  let prompt = `# ASO Optimization - Stage 1: Primary Locale
1199
1207
 
1200
1208
  `;
@@ -1204,32 +1212,33 @@ function generatePrimaryOptimizationPrompt(args) {
1204
1212
  prompt += `## Task
1205
1213
 
1206
1214
  `;
1207
- prompt += `Optimize the PRIMARY locale (${primaryLocale}) with keyword research + full ASO field optimization.
1215
+ prompt += `Optimize the PRIMARY locale (${primaryLocale}) using **saved keyword research** + full ASO field optimization.
1208
1216
 
1209
1217
  `;
1210
1218
  prompt += `## Step 1: Keyword Research (${primaryLocale})
1211
1219
 
1212
1220
  `;
1213
- prompt += `**Strategies** (apply all 5):
1214
- `;
1215
- prompt += `1. **App Store**: Search top apps in category \u2192 extract keywords from titles/descriptions
1216
- `;
1217
- prompt += `2. **Description-Based**: Use current shortDescription \u2192 find related ASO keywords
1218
- `;
1219
- prompt += `3. **Feature-Based**: Identify 2-3 core features \u2192 search feature-specific keywords
1220
- `;
1221
- prompt += `4. **User Intent**: Research user search patterns for the app's use case
1221
+ const researchSections = keywordResearchByLocale[primaryLocale] || [];
1222
+ const researchDir = keywordResearchDirByLocale[primaryLocale];
1223
+ if (researchSections.length > 0) {
1224
+ prompt += `Use the **saved keyword research below**. Do NOT invent new keywords. Choose the top 10 from the recommended set.
1225
+
1222
1226
  `;
1223
- prompt += `5. **Competitor**: Analyze 3+ successful apps \u2192 find common keyword patterns
1227
+ prompt += `Saved research:
1228
+ ${researchSections.join("\n")}
1224
1229
 
1225
1230
  `;
1226
- prompt += `**Output**: 10 high-performing keywords
1231
+ } else {
1232
+ prompt += `No saved keyword research found at ${researchDir}.
1233
+ `;
1234
+ prompt += `**Stop and request action**: Run the 'keyword-research' tool with slug='${slug}', locale='${primaryLocale}', and the appropriate platform/country, then rerun improve-public stage 1.
1227
1235
 
1228
1236
  `;
1237
+ }
1229
1238
  prompt += `## Step 2: Optimize All Fields (${primaryLocale})
1230
1239
 
1231
1240
  `;
1232
- prompt += `Apply the 10 keywords to ALL fields:
1241
+ prompt += `Apply the selected keywords to ALL fields:
1233
1242
  `;
1234
1243
  prompt += `- \`aso.title\` (\u226430): **"App Name: Primary Keyword"** format (app name in English, keyword in target language, keyword starts with uppercase after the colon)
1235
1244
  `;
@@ -1284,11 +1293,11 @@ function generatePrimaryOptimizationPrompt(args) {
1284
1293
  prompt += `## Output Format
1285
1294
 
1286
1295
  `;
1287
- prompt += `**1. Keyword Research**
1296
+ prompt += `**1. Keyword Research (from saved data)**
1288
1297
  `;
1289
- prompt += ` - Query: "[query]" \u2192 Found: [apps] \u2192 Keywords: [list]
1298
+ prompt += ` - Cite file(s) used and list the selected top 10 keywords (no new research)
1290
1299
  `;
1291
- prompt += ` - Final 10 keywords: [list] with rationale
1300
+ prompt += ` - Rationale: why these 10 were chosen from saved research
1292
1301
 
1293
1302
  `;
1294
1303
  prompt += `**2. Optimized JSON** (complete ${primaryLocale} locale structure)
@@ -1336,6 +1345,8 @@ function generateKeywordLocalizationPrompt(args) {
1336
1345
  targetLocales,
1337
1346
  localeSections,
1338
1347
  optimizedPrimary,
1348
+ keywordResearchByLocale,
1349
+ keywordResearchDirByLocale,
1339
1350
  batchLocales,
1340
1351
  batchIndex,
1341
1352
  totalBatches,
@@ -1379,7 +1390,7 @@ function generateKeywordLocalizationPrompt(args) {
1379
1390
  `;
1380
1391
  prompt += `For EACH target locale in this batch:
1381
1392
  `;
1382
- prompt += `1. Research 10 language-specific keywords
1393
+ prompt += `1. Use SAVED keyword research (see per-locale data below). Do NOT invent keywords.
1383
1394
  `;
1384
1395
  prompt += `2. Replace keywords in translated content (preserve structure/tone/context)
1385
1396
  `;
@@ -1400,18 +1411,22 @@ ${optimizedPrimary}
1400
1411
  prompt += `## Keyword Research (Per Locale)
1401
1412
 
1402
1413
  `;
1403
- prompt += `For EACH locale, perform lightweight keyword research:
1404
- `;
1405
- prompt += `1. **App Store Search**: "[core feature] [lang] \uC571/app" \u2192 top 5 apps
1406
- `;
1407
- prompt += `2. **Competitor Keywords**: Extract keywords from successful apps in that language
1408
- `;
1409
- prompt += `3. **Search Trends**: Check what users actually search in that language
1414
+ nonPrimaryLocales.forEach((loc) => {
1415
+ const researchSections = keywordResearchByLocale[loc] || [];
1416
+ const researchDir = keywordResearchDirByLocale[loc];
1417
+ if (researchSections.length > 0) {
1418
+ prompt += `Locale ${loc}: use saved research below. Do NOT invent keywords.
1419
+ ${researchSections.join(
1420
+ "\n"
1421
+ )}
1410
1422
 
1411
1423
  `;
1412
- prompt += `**Output**: 10 keywords per locale
1424
+ } else {
1425
+ prompt += `Locale ${loc}: no saved keyword research found at ${researchDir}. Stop and request running 'keyword-research' tool (slug='${slug}', locale='${loc}', platform/country as appropriate\u2014match the store locale), then rerun stage 2.
1413
1426
 
1414
1427
  `;
1428
+ }
1429
+ });
1415
1430
  prompt += `## Keyword Replacement Strategy
1416
1431
 
1417
1432
  `;
@@ -1492,7 +1507,7 @@ ${optimizedPrimary}
1492
1507
  `;
1493
1508
  prompt += `Process EACH locale in this batch sequentially:
1494
1509
  `;
1495
- prompt += `1. Research 10 keywords for locale
1510
+ prompt += `1. Use saved keyword research (or pause if missing and request keyword-research run)
1496
1511
  `;
1497
1512
  prompt += `2. Replace keywords in ALL fields:
1498
1513
  `;
@@ -1548,11 +1563,11 @@ ${optimizedPrimary}
1548
1563
  prompt += `### Locale [locale-code]:
1549
1564
 
1550
1565
  `;
1551
- prompt += `**1. Keyword Research**
1566
+ prompt += `**1. Keyword Research (saved)**
1552
1567
  `;
1553
- prompt += ` - Query: "[query]" \u2192 Keywords: [list]
1568
+ prompt += ` - Cite file(s) used; list selected top 10 keywords (no new research)
1554
1569
  `;
1555
- prompt += ` - Final 10: [list] with rationale
1570
+ prompt += ` - Rationale: why these were chosen from saved research
1556
1571
 
1557
1572
  `;
1558
1573
  prompt += `**2. Updated JSON** (complete locale structure with keyword replacements)
@@ -1596,6 +1611,92 @@ ${optimizedPrimary}
1596
1611
  return prompt;
1597
1612
  }
1598
1613
 
1614
+ // src/tools/utils/improve-public/load-keyword-research.util.ts
1615
+ import fs6 from "fs";
1616
+ import path6 from "path";
1617
+ function extractRecommended(data) {
1618
+ const summary = data?.summary || data?.data?.summary;
1619
+ const recommended = summary?.recommendedKeywords;
1620
+ if (Array.isArray(recommended)) {
1621
+ return recommended.map(String);
1622
+ }
1623
+ if (typeof recommended === "string") {
1624
+ return [recommended];
1625
+ }
1626
+ return [];
1627
+ }
1628
+ function extractMeta(data) {
1629
+ const meta = data?.meta || data?.data?.meta || {};
1630
+ return {
1631
+ platform: meta.platform,
1632
+ country: meta.country,
1633
+ seedKeywords: Array.isArray(meta.seedKeywords) ? meta.seedKeywords.map(String) : void 0,
1634
+ competitorApps: Array.isArray(meta.competitorApps) ? meta.competitorApps : void 0
1635
+ };
1636
+ }
1637
+ function formatEntry(entry) {
1638
+ const { filePath, data } = entry;
1639
+ const recommended = extractRecommended(data);
1640
+ const meta = extractMeta(data);
1641
+ if (data?.parseError) {
1642
+ return `File: ${filePath}
1643
+ Parse error: ${data.parseError}
1644
+ ----`;
1645
+ }
1646
+ const lines = [];
1647
+ lines.push(`File: ${filePath}`);
1648
+ if (meta.platform || meta.country) {
1649
+ lines.push(
1650
+ `Platform: ${meta.platform || "unknown"} | Country: ${meta.country || "unknown"}`
1651
+ );
1652
+ }
1653
+ if (meta.seedKeywords?.length) {
1654
+ lines.push(`Seeds: ${meta.seedKeywords.join(", ")}`);
1655
+ }
1656
+ if (meta.competitorApps?.length) {
1657
+ const competitors = meta.competitorApps.map((c) => `${c.platform || "?"}:${c.appId || "?"}`).join(", ");
1658
+ lines.push(`Competitors: ${competitors}`);
1659
+ }
1660
+ if (recommended.length) {
1661
+ lines.push(`Recommended keywords (${recommended.length}): ${recommended.join(", ")}`);
1662
+ } else {
1663
+ lines.push("Recommended keywords: (not provided)");
1664
+ }
1665
+ lines.push("----");
1666
+ return lines.join("\n");
1667
+ }
1668
+ function loadKeywordResearchForLocale(slug, locale) {
1669
+ const researchDir = path6.join(
1670
+ getKeywordResearchDir(),
1671
+ "products",
1672
+ slug,
1673
+ "locales",
1674
+ locale
1675
+ );
1676
+ if (!fs6.existsSync(researchDir)) {
1677
+ return { entries: [], sections: [], researchDir };
1678
+ }
1679
+ const files = fs6.readdirSync(researchDir).filter((file) => file.endsWith(".json"));
1680
+ const entries = [];
1681
+ for (const file of files) {
1682
+ const filePath = path6.join(researchDir, file);
1683
+ try {
1684
+ const raw = fs6.readFileSync(filePath, "utf-8");
1685
+ const data = JSON.parse(raw);
1686
+ entries.push({ filePath, data });
1687
+ } catch (err) {
1688
+ entries.push({
1689
+ filePath,
1690
+ data: {
1691
+ parseError: err instanceof Error ? err.message : "Unknown parse error"
1692
+ }
1693
+ });
1694
+ }
1695
+ }
1696
+ const sections = entries.map(formatEntry);
1697
+ return { entries, sections, researchDir };
1698
+ }
1699
+
1599
1700
  // src/tools/improve-public.ts
1600
1701
  var FIELD_LIMITS_DOC_PATH3 = "docs/aso/ASO_FIELD_LIMITS.md";
1601
1702
  var toJsonSchema3 = zodToJsonSchema3;
@@ -1712,12 +1813,21 @@ async function handleImprovePublic(input) {
1712
1813
  })
1713
1814
  );
1714
1815
  }
1816
+ const keywordResearchByLocale = {};
1817
+ const keywordResearchDirByLocale = {};
1818
+ for (const loc of targetLocales) {
1819
+ const research = loadKeywordResearchForLocale(slug, loc);
1820
+ keywordResearchByLocale[loc] = research.sections;
1821
+ keywordResearchDirByLocale[loc] = research.researchDir;
1822
+ }
1715
1823
  const baseArgs = {
1716
1824
  slug,
1717
1825
  category,
1718
1826
  primaryLocale,
1719
1827
  targetLocales,
1720
- localeSections
1828
+ localeSections,
1829
+ keywordResearchByLocale,
1830
+ keywordResearchDirByLocale
1721
1831
  };
1722
1832
  if (stage === "1" || stage === "both") {
1723
1833
  const prompt = generatePrimaryOptimizationPrompt(baseArgs);
@@ -1763,6 +1873,8 @@ async function handleImprovePublic(input) {
1763
1873
  primaryLocale: baseArgs.primaryLocale,
1764
1874
  targetLocales: baseArgs.targetLocales,
1765
1875
  localeSections: baseArgs.localeSections,
1876
+ keywordResearchByLocale: baseArgs.keywordResearchByLocale,
1877
+ keywordResearchDirByLocale: baseArgs.keywordResearchDirByLocale,
1766
1878
  optimizedPrimary,
1767
1879
  batchLocales,
1768
1880
  batchIndex: currentBatchIndex,
@@ -1785,13 +1897,13 @@ async function handleImprovePublic(input) {
1785
1897
  }
1786
1898
 
1787
1899
  // src/tools/init-project.ts
1788
- import fs6 from "fs";
1789
- import path6 from "path";
1900
+ import fs7 from "fs";
1901
+ import path7 from "path";
1790
1902
  import { z as z4 } from "zod";
1791
1903
  import { zodToJsonSchema as zodToJsonSchema4 } from "zod-to-json-schema";
1792
1904
  var listSlugDirs = (dir) => {
1793
- if (!fs6.existsSync(dir)) return [];
1794
- return fs6.readdirSync(dir, { withFileTypes: true }).filter((dirent) => dirent.isDirectory()).map((dirent) => dirent.name);
1905
+ if (!fs7.existsSync(dir)) return [];
1906
+ return fs7.readdirSync(dir, { withFileTypes: true }).filter((dirent) => dirent.isDirectory()).map((dirent) => dirent.name);
1795
1907
  };
1796
1908
  var initProjectInputSchema = z4.object({
1797
1909
  slug: z4.string().trim().optional().describe(
@@ -1816,7 +1928,7 @@ Steps:
1816
1928
  inputSchema: inputSchema4
1817
1929
  };
1818
1930
  async function handleInitProject(input) {
1819
- const pullDataDir = path6.join(getPullDataDir(), "products");
1931
+ const pullDataDir = path7.join(getPullDataDir(), "products");
1820
1932
  const publicDir = getProductsDir();
1821
1933
  const pullDataSlugs = listSlugDirs(pullDataDir);
1822
1934
  const publicSlugs = listSlugDirs(publicDir);
@@ -1894,14 +2006,14 @@ async function handleInitProject(input) {
1894
2006
  }
1895
2007
 
1896
2008
  // src/tools/create-blog-html.ts
1897
- import fs8 from "fs";
1898
- import path8 from "path";
2009
+ import fs9 from "fs";
2010
+ import path9 from "path";
1899
2011
  import { z as z5 } from "zod";
1900
2012
  import { zodToJsonSchema as zodToJsonSchema5 } from "zod-to-json-schema";
1901
2013
 
1902
2014
  // src/utils/blog.util.ts
1903
- import fs7 from "fs";
1904
- import path7 from "path";
2015
+ import fs8 from "fs";
2016
+ import path8 from "path";
1905
2017
  var DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
1906
2018
  var BLOG_ROOT = "blogs";
1907
2019
  var removeDiacritics = (value) => value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
@@ -1989,13 +2101,13 @@ function resolveTargetLocales(input) {
1989
2101
  return fallback ? [fallback] : [];
1990
2102
  }
1991
2103
  function getBlogOutputPaths(options) {
1992
- const baseDir = path7.join(
2104
+ const baseDir = path8.join(
1993
2105
  options.publicDir,
1994
2106
  BLOG_ROOT,
1995
2107
  options.appSlug,
1996
2108
  options.slug
1997
2109
  );
1998
- const filePath = path7.join(baseDir, `${options.locale}.html`);
2110
+ const filePath = path8.join(baseDir, `${options.locale}.html`);
1999
2111
  const publicBasePath = toPublicBlogBase(options.appSlug, options.slug);
2000
2112
  return { baseDir, filePath, publicBasePath };
2001
2113
  }
@@ -2020,18 +2132,18 @@ function findExistingBlogPosts({
2020
2132
  publicDir,
2021
2133
  limit = 2
2022
2134
  }) {
2023
- const blogAppDir = path7.join(publicDir, BLOG_ROOT, appSlug);
2024
- if (!fs7.existsSync(blogAppDir)) {
2135
+ const blogAppDir = path8.join(publicDir, BLOG_ROOT, appSlug);
2136
+ if (!fs8.existsSync(blogAppDir)) {
2025
2137
  return [];
2026
2138
  }
2027
2139
  const posts = [];
2028
- const subdirs = fs7.readdirSync(blogAppDir, { withFileTypes: true });
2140
+ const subdirs = fs8.readdirSync(blogAppDir, { withFileTypes: true });
2029
2141
  for (const subdir of subdirs) {
2030
2142
  if (!subdir.isDirectory()) continue;
2031
- const localeFile = path7.join(blogAppDir, subdir.name, `${locale}.html`);
2032
- if (!fs7.existsSync(localeFile)) continue;
2143
+ const localeFile = path8.join(blogAppDir, subdir.name, `${locale}.html`);
2144
+ if (!fs8.existsSync(localeFile)) continue;
2033
2145
  try {
2034
- const htmlContent = fs7.readFileSync(localeFile, "utf-8");
2146
+ const htmlContent = fs8.readFileSync(localeFile, "utf-8");
2035
2147
  const { meta, body } = parseBlogHtml(htmlContent);
2036
2148
  if (meta && meta.locale === locale) {
2037
2149
  posts.push({
@@ -2174,7 +2286,7 @@ async function handleCreateBlogHtml(input) {
2174
2286
  }
2175
2287
  const output = {
2176
2288
  slug,
2177
- baseDir: path8.join(publicDir, "blogs", appSlug, slug),
2289
+ baseDir: path9.join(publicDir, "blogs", appSlug, slug),
2178
2290
  files: [],
2179
2291
  coverImage: coverImage && coverImage.trim().length > 0 ? coverImage.trim() : `/products/${appSlug}/og-image.png`,
2180
2292
  metaByLocale: {}
@@ -2188,7 +2300,7 @@ async function handleCreateBlogHtml(input) {
2188
2300
  })
2189
2301
  );
2190
2302
  const existing = plannedFiles.filter(
2191
- ({ filePath }) => fs8.existsSync(filePath)
2303
+ ({ filePath }) => fs9.existsSync(filePath)
2192
2304
  );
2193
2305
  if (existing.length > 0 && !overwrite) {
2194
2306
  const existingList = existing.map((f) => f.filePath).join("\n- ");
@@ -2197,7 +2309,7 @@ async function handleCreateBlogHtml(input) {
2197
2309
  - ${existingList}`
2198
2310
  );
2199
2311
  }
2200
- fs8.mkdirSync(output.baseDir, { recursive: true });
2312
+ fs9.mkdirSync(output.baseDir, { recursive: true });
2201
2313
  for (const locale of targetLocales) {
2202
2314
  const { filePath } = getBlogOutputPaths({
2203
2315
  appSlug,
@@ -2223,7 +2335,7 @@ async function handleCreateBlogHtml(input) {
2223
2335
  meta,
2224
2336
  content
2225
2337
  });
2226
- fs8.writeFileSync(filePath, html, "utf-8");
2338
+ fs9.writeFileSync(filePath, html, "utf-8");
2227
2339
  output.files.push({ locale, path: filePath });
2228
2340
  }
2229
2341
  const summaryLines = [
@@ -2259,6 +2371,243 @@ Writing style reference for ${locale}: Found ${posts.length} existing post(s) us
2259
2371
  };
2260
2372
  }
2261
2373
 
2374
+ // src/tools/keyword-research.ts
2375
+ import fs10 from "fs";
2376
+ import path10 from "path";
2377
+ import { z as z6 } from "zod";
2378
+ import { zodToJsonSchema as zodToJsonSchema6 } from "zod-to-json-schema";
2379
+ var TOOL_NAME = "keyword-research";
2380
+ var keywordResearchInputSchema = z6.object({
2381
+ slug: z6.string().trim().describe("Product slug"),
2382
+ locale: z6.string().trim().describe("Locale code (e.g., en-US, ko-KR). Used for storage under .aso/keywordResearch/products/[slug]/locales/."),
2383
+ platform: z6.enum(["ios", "android"]).default("ios").describe("Store to target ('ios' or 'android'). Run separately per platform."),
2384
+ country: z6.string().length(2).optional().describe(
2385
+ "Two-letter store country code. If omitted, derived from locale region (e.g., ko-KR -> kr), else 'us'."
2386
+ ),
2387
+ seedKeywords: z6.array(z6.string().trim()).default([]).describe("Seed keywords to start from."),
2388
+ competitorApps: z6.array(
2389
+ z6.object({
2390
+ appId: z6.string().trim().describe("App ID (package name or iOS ID/bundle)"),
2391
+ platform: z6.enum(["ios", "android"])
2392
+ })
2393
+ ).default([]).describe("Known competitor apps to probe."),
2394
+ filename: z6.string().trim().optional().describe("Override output filename. Defaults to keyword-research-[platform]-[country].json"),
2395
+ writeTemplate: z6.boolean().default(false).describe("If true, write a JSON template at the output path."),
2396
+ researchData: z6.string().trim().optional().describe(
2397
+ "Optional JSON string with research results (e.g., from mcp-appstore tools). If provided, saves it to the output path."
2398
+ )
2399
+ });
2400
+ var jsonSchema6 = zodToJsonSchema6(keywordResearchInputSchema, {
2401
+ name: "KeywordResearchInput",
2402
+ $refStrategy: "none"
2403
+ });
2404
+ var inputSchema6 = jsonSchema6.definitions?.KeywordResearchInput || jsonSchema6;
2405
+ var keywordResearchTool = {
2406
+ name: TOOL_NAME,
2407
+ description: `Prep + persist keyword research ahead of improve-public using mcp-appstore outputs.
2408
+
2409
+ Run this before improve-public. It gives a concrete MCP-powered research plan and a storage path under .aso/keywordResearch/products/[slug]/locales/[locale]/. Optionally writes a template or saves raw JSON from mcp-appstore tools.`,
2410
+ inputSchema: inputSchema6
2411
+ };
2412
+ function buildTemplate({
2413
+ slug,
2414
+ locale,
2415
+ platform,
2416
+ country,
2417
+ seedKeywords,
2418
+ competitorApps
2419
+ }) {
2420
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2421
+ return {
2422
+ meta: {
2423
+ slug,
2424
+ locale,
2425
+ platform,
2426
+ country,
2427
+ seedKeywords,
2428
+ competitorApps,
2429
+ source: "mcp-appstore",
2430
+ updatedAt: timestamp
2431
+ },
2432
+ plan: {
2433
+ steps: [
2434
+ "Start mcp-appstore server (npm start in external-tools/mcp-appstore).",
2435
+ "Discover competitors: search_app(term=seed keyword), get_similar_apps(appId=known competitor).",
2436
+ "Collect candidates: suggest_keywords_by_seeds, suggest_keywords_by_category, suggest_keywords_by_similarity, suggest_keywords_by_competition.",
2437
+ "Score shortlist: get_keyword_scores for 15\u201330 candidates per platform/country.",
2438
+ "Context check: analyze_reviews on top apps for language/tone cues."
2439
+ ],
2440
+ note: "Run per platform/country. Save raw tool outputs plus curated top keywords."
2441
+ },
2442
+ data: {
2443
+ raw: {
2444
+ searchApp: [],
2445
+ keywordSuggestions: {
2446
+ bySeeds: [],
2447
+ byCategory: [],
2448
+ bySimilarity: [],
2449
+ byCompetition: [],
2450
+ bySearchHints: []
2451
+ },
2452
+ keywordScores: [],
2453
+ reviewsAnalysis: []
2454
+ },
2455
+ summary: {
2456
+ recommendedKeywords: [],
2457
+ rationale: "",
2458
+ nextActions: "Feed top 10\u201315 into improve-public Stage 1."
2459
+ }
2460
+ }
2461
+ };
2462
+ }
2463
+ function saveJsonFile({
2464
+ researchDir,
2465
+ fileName,
2466
+ payload
2467
+ }) {
2468
+ fs10.mkdirSync(researchDir, { recursive: true });
2469
+ const outputPath = path10.join(researchDir, fileName);
2470
+ fs10.writeFileSync(outputPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
2471
+ return outputPath;
2472
+ }
2473
+ function normalizeKeywords(raw) {
2474
+ if (!raw) return [];
2475
+ if (Array.isArray(raw)) {
2476
+ return raw.map((k) => k.trim()).filter((k) => k.length > 0);
2477
+ }
2478
+ return raw.split(",").map((k) => k.trim()).filter((k) => k.length > 0);
2479
+ }
2480
+ async function handleKeywordResearch(input) {
2481
+ const {
2482
+ slug,
2483
+ locale,
2484
+ platform = "ios",
2485
+ country,
2486
+ seedKeywords = [],
2487
+ competitorApps = [],
2488
+ filename,
2489
+ writeTemplate = false,
2490
+ researchData
2491
+ } = input;
2492
+ const { config, locales } = loadProductLocales(slug);
2493
+ const primaryLocale = resolvePrimaryLocale(config, locales);
2494
+ const primaryLocaleData = locales[primaryLocale];
2495
+ const autoSeeds = [];
2496
+ const autoCompetitors = [];
2497
+ if (primaryLocaleData?.aso?.title) {
2498
+ autoSeeds.push(primaryLocaleData.aso.title);
2499
+ }
2500
+ const parsedKeywords = normalizeKeywords(primaryLocaleData?.aso?.keywords);
2501
+ autoSeeds.push(...parsedKeywords.slice(0, 5));
2502
+ if (config?.name) autoSeeds.push(config.name);
2503
+ if (config?.tagline) autoSeeds.push(config.tagline);
2504
+ if (platform === "ios") {
2505
+ if (config?.appStoreAppId) {
2506
+ autoCompetitors.push({ appId: String(config.appStoreAppId), platform });
2507
+ } else if (config?.bundleId) {
2508
+ autoCompetitors.push({ appId: config.bundleId, platform });
2509
+ }
2510
+ } else if (platform === "android" && config?.packageName) {
2511
+ autoCompetitors.push({ appId: config.packageName, platform });
2512
+ }
2513
+ const resolvedSeeds = seedKeywords.length > 0 ? seedKeywords : Array.from(new Set(autoSeeds));
2514
+ const resolvedCompetitors = competitorApps.length > 0 ? competitorApps : autoCompetitors;
2515
+ const resolvedCountry = country || (locale?.includes("-") ? locale.split("-")[1].toLowerCase() : "us");
2516
+ const researchDir = path10.join(
2517
+ getKeywordResearchDir(),
2518
+ "products",
2519
+ slug,
2520
+ "locales",
2521
+ locale
2522
+ );
2523
+ const defaultFileName = `keyword-research-${platform}-${resolvedCountry}.json`;
2524
+ const fileName = filename || defaultFileName;
2525
+ let outputPath = path10.join(researchDir, fileName);
2526
+ let fileAction;
2527
+ if (writeTemplate || researchData) {
2528
+ const payload = researchData ? (() => {
2529
+ try {
2530
+ return JSON.parse(researchData);
2531
+ } catch (err) {
2532
+ throw new Error(
2533
+ `Failed to parse researchData JSON: ${err instanceof Error ? err.message : String(err)}`
2534
+ );
2535
+ }
2536
+ })() : buildTemplate({
2537
+ slug,
2538
+ locale,
2539
+ platform,
2540
+ country: resolvedCountry,
2541
+ seedKeywords: resolvedSeeds,
2542
+ competitorApps: resolvedCompetitors
2543
+ });
2544
+ outputPath = saveJsonFile({ researchDir, fileName, payload });
2545
+ fileAction = researchData ? "Saved provided researchData" : "Wrote template";
2546
+ }
2547
+ const templatePreview = JSON.stringify(
2548
+ buildTemplate({
2549
+ slug,
2550
+ locale,
2551
+ platform,
2552
+ country: resolvedCountry,
2553
+ seedKeywords: resolvedSeeds,
2554
+ competitorApps: resolvedCompetitors
2555
+ }),
2556
+ null,
2557
+ 2
2558
+ );
2559
+ const lines = [];
2560
+ lines.push(`# Keyword research plan (${slug})`);
2561
+ lines.push(`Locale: ${locale} | Platform: ${platform} | Country: ${resolvedCountry}`);
2562
+ lines.push(`Primary locale detected: ${primaryLocale}`);
2563
+ lines.push(
2564
+ `Seeds: ${resolvedSeeds.length > 0 ? resolvedSeeds.join(", ") : "(none set; add seedKeywords or ensure ASO keywords/title exist)"}`
2565
+ );
2566
+ lines.push(
2567
+ `Competitors (from config if empty): ${resolvedCompetitors.length > 0 ? resolvedCompetitors.map((c) => `${c.platform}:${c.appId}`).join(", ") : "(none set; add competitorApps or set appStoreAppId/bundleId/packageName in config.json)"}`
2568
+ );
2569
+ lines.push("");
2570
+ lines.push("How to run (uses mcp-appstore):");
2571
+ lines.push(
2572
+ `1) Start the local mcp-appstore server for this run: node server.js (cwd: /ABSOLUTE/PATH/TO/pabal-web-mcp/external-tools/mcp-appstore). LLM should start it before calling tools and stop it after, if the client supports process management; otherwise, start/stop manually.`
2573
+ );
2574
+ lines.push(
2575
+ `2) Discover apps: search_app(term=seed, platform=${platform}, country=${country}); get_similar_apps(appId=known competitor).`
2576
+ );
2577
+ lines.push(
2578
+ `3) Expand keywords: suggest_keywords_by_seeds, suggest_keywords_by_category, suggest_keywords_by_similarity, suggest_keywords_by_competition, suggest_keywords_by_search.`
2579
+ );
2580
+ lines.push(
2581
+ `4) Score shortlist: get_keyword_scores for 15\u201330 candidates (note: scores are heuristic per README).`
2582
+ );
2583
+ lines.push(
2584
+ `5) Context check: analyze_reviews on top apps to harvest native phrasing; keep snippets for improve-public.`
2585
+ );
2586
+ lines.push(
2587
+ `6) Save all raw responses + your final top 10\u201315 keywords to: ${outputPath} (structure mirrors .aso/pullData/.aso/pushData under products/<slug>/locales/<locale>)`
2588
+ );
2589
+ if (fileAction) {
2590
+ lines.push(`File: ${fileAction} at ${outputPath}`);
2591
+ } else {
2592
+ lines.push(
2593
+ `Tip: set writeTemplate=true to create the JSON skeleton at ${outputPath}`
2594
+ );
2595
+ }
2596
+ lines.push("");
2597
+ lines.push("Suggested JSON shape:");
2598
+ lines.push("```json");
2599
+ lines.push(templatePreview);
2600
+ lines.push("```");
2601
+ return {
2602
+ content: [
2603
+ {
2604
+ type: "text",
2605
+ text: lines.join("\n")
2606
+ }
2607
+ ]
2608
+ };
2609
+ }
2610
+
2262
2611
  // src/tools/index.ts
2263
2612
  var tools = [
2264
2613
  {
@@ -2300,6 +2649,14 @@ var tools = [
2300
2649
  zodSchema: createBlogHtmlInputSchema,
2301
2650
  handler: handleCreateBlogHtml,
2302
2651
  category: "Content"
2652
+ },
2653
+ {
2654
+ name: keywordResearchTool.name,
2655
+ description: keywordResearchTool.description,
2656
+ inputSchema: keywordResearchTool.inputSchema,
2657
+ zodSchema: keywordResearchInputSchema,
2658
+ handler: handleKeywordResearch,
2659
+ category: "ASO Research"
2303
2660
  }
2304
2661
  ];
2305
2662
  function getToolDefinitions() {
@@ -2308,7 +2665,8 @@ function getToolDefinitions() {
2308
2665
  publicToAsoTool,
2309
2666
  improvePublicTool,
2310
2667
  initProjectTool,
2311
- createBlogHtmlTool
2668
+ createBlogHtmlTool,
2669
+ keywordResearchTool
2312
2670
  ];
2313
2671
  }
2314
2672
  function getToolHandler(name) {
@@ -0,0 +1,355 @@
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
+
61
+ // src/utils/aso-converter.ts
62
+ import fs2 from "fs";
63
+ import path2 from "path";
64
+ function generateFullDescription(localeData, metadata = {}) {
65
+ const { aso, landing } = localeData;
66
+ const template = aso?.template;
67
+ if (!template) {
68
+ return "";
69
+ }
70
+ const landingFeatures = landing?.features?.items || [];
71
+ const landingScreenshots = landing?.screenshots?.images || [];
72
+ const keyHeading = template.keyFeaturesHeading || "Key Features";
73
+ const featuresHeading = template.featuresHeading || "Additional Features";
74
+ const parts = [template.intro];
75
+ if (landingFeatures.length > 0) {
76
+ parts.push(
77
+ "",
78
+ keyHeading,
79
+ "",
80
+ ...landingFeatures.map(
81
+ (feature) => [`\u25B6\uFE0E ${feature.title}`, feature.body || ""].filter(Boolean).join("\n")
82
+ )
83
+ );
84
+ }
85
+ if (landingScreenshots.length > 0) {
86
+ parts.push("", featuresHeading, "");
87
+ parts.push(
88
+ ...landingScreenshots.map(
89
+ (screenshot) => [`\u25B6\uFE0E ${screenshot.title}`, screenshot.description || ""].filter(Boolean).join("\n")
90
+ )
91
+ );
92
+ }
93
+ parts.push("", template.outro);
94
+ const includeSupport = template.includeSupportLinks ?? true;
95
+ if (includeSupport) {
96
+ const contactLines = [
97
+ metadata.instagram ? `Instagram: ${metadata.instagram}` : null,
98
+ metadata.contactEmail ? `Email: ${metadata.contactEmail}` : null,
99
+ metadata.termsUrl ? `- Terms of Use: ${metadata.termsUrl}` : null,
100
+ metadata.privacyUrl ? `- Privacy Policy: ${metadata.privacyUrl}` : null
101
+ ].filter((line) => line !== null);
102
+ if (contactLines.length > 0) {
103
+ parts.push("", "[Contact & Support]", "", ...contactLines);
104
+ }
105
+ }
106
+ return parts.join("\n");
107
+ }
108
+ function loadAsoFromConfig(slug) {
109
+ const productsDir = getProductsDir();
110
+ const configPath = path2.join(productsDir, slug, "config.json");
111
+ console.debug(`[loadAsoFromConfig] Looking for ${slug}:`);
112
+ console.debug(` - productsDir: ${productsDir}`);
113
+ console.debug(` - configPath: ${configPath}`);
114
+ console.debug(` - configPath exists: ${fs2.existsSync(configPath)}`);
115
+ if (!fs2.existsSync(configPath)) {
116
+ console.warn(`[loadAsoFromConfig] Config file not found at ${configPath}`);
117
+ return {};
118
+ }
119
+ try {
120
+ const configContent = fs2.readFileSync(configPath, "utf-8");
121
+ const config = JSON.parse(configContent);
122
+ const localesDir = path2.join(productsDir, slug, "locales");
123
+ console.debug(` - localesDir: ${localesDir}`);
124
+ console.debug(` - localesDir exists: ${fs2.existsSync(localesDir)}`);
125
+ if (!fs2.existsSync(localesDir)) {
126
+ console.warn(
127
+ `[loadAsoFromConfig] Locales directory not found at ${localesDir}`
128
+ );
129
+ return {};
130
+ }
131
+ const localeFiles = fs2.readdirSync(localesDir).filter((f) => f.endsWith(".json"));
132
+ const locales = {};
133
+ for (const file of localeFiles) {
134
+ const localeCode = file.replace(".json", "");
135
+ const localePath = path2.join(localesDir, file);
136
+ const localeContent = fs2.readFileSync(localePath, "utf-8");
137
+ locales[localeCode] = JSON.parse(localeContent);
138
+ }
139
+ console.debug(
140
+ ` - Found ${Object.keys(locales).length} locale file(s): ${Object.keys(
141
+ locales
142
+ ).join(", ")}`
143
+ );
144
+ if (Object.keys(locales).length === 0) {
145
+ console.warn(
146
+ `[loadAsoFromConfig] No locale files found in ${localesDir}`
147
+ );
148
+ }
149
+ const defaultLocale = config.content?.defaultLocale || DEFAULT_LOCALE;
150
+ const asoData = {};
151
+ if (config.packageName) {
152
+ const googlePlayLocales = {};
153
+ const metadata = config.metadata || {};
154
+ const screenshots = metadata.screenshots || {};
155
+ for (const [locale, localeData] of Object.entries(locales)) {
156
+ if (!isSupportedLocale(locale)) {
157
+ console.debug(
158
+ `Skipping locale ${locale} - not a valid unified locale`
159
+ );
160
+ continue;
161
+ }
162
+ if (!isGooglePlayLocale(locale)) {
163
+ console.debug(
164
+ `Skipping locale ${locale} - not supported by Google Play`
165
+ );
166
+ continue;
167
+ }
168
+ const aso = localeData.aso || {};
169
+ if (!aso || !aso.title && !aso.shortDescription) {
170
+ console.warn(
171
+ `Locale ${locale} has no ASO data (title or shortDescription)`
172
+ );
173
+ }
174
+ const localeScreenshots = {
175
+ phone: screenshots.phone?.map(
176
+ (p) => p.replace("/screenshots/", `/screenshots/${locale}/`)
177
+ ),
178
+ tablet: screenshots.tablet?.map(
179
+ (p) => p.replace("/screenshots/", `/screenshots/${locale}/`)
180
+ )
181
+ };
182
+ googlePlayLocales[locale] = {
183
+ title: aso.title || "",
184
+ shortDescription: aso.shortDescription || "",
185
+ fullDescription: generateFullDescription(localeData, metadata),
186
+ packageName: config.packageName,
187
+ defaultLanguage: locale,
188
+ screenshots: {
189
+ phone: localeScreenshots.phone || [],
190
+ tablet: localeScreenshots.tablet
191
+ },
192
+ contactEmail: metadata.contactEmail
193
+ };
194
+ }
195
+ const googleLocaleKeys = Object.keys(googlePlayLocales);
196
+ if (googleLocaleKeys.length > 0) {
197
+ const hasConfigDefault = isGooglePlayLocale(defaultLocale) && Boolean(googlePlayLocales[defaultLocale]);
198
+ const resolvedDefault = hasConfigDefault ? defaultLocale : googlePlayLocales[DEFAULT_LOCALE] ? DEFAULT_LOCALE : googleLocaleKeys[0];
199
+ asoData.googlePlay = {
200
+ locales: googlePlayLocales,
201
+ defaultLocale: resolvedDefault
202
+ };
203
+ }
204
+ }
205
+ if (config.bundleId) {
206
+ const appStoreLocales = {};
207
+ const metadata = config.metadata || {};
208
+ const screenshots = metadata.screenshots || {};
209
+ for (const [locale, localeData] of Object.entries(locales)) {
210
+ if (!isSupportedLocale(locale)) {
211
+ console.debug(
212
+ `Skipping locale ${locale} - not a valid unified locale`
213
+ );
214
+ continue;
215
+ }
216
+ if (!isAppStoreLocale(locale)) {
217
+ console.debug(
218
+ `Skipping locale ${locale} - not supported by App Store`
219
+ );
220
+ continue;
221
+ }
222
+ const aso = localeData.aso || {};
223
+ if (!aso || !aso.title && !aso.shortDescription) {
224
+ console.warn(
225
+ `Locale ${locale} has no ASO data (title or shortDescription)`
226
+ );
227
+ }
228
+ const localeScreenshots = {
229
+ phone: screenshots.phone?.map(
230
+ (p) => p.replace("/screenshots/", `/screenshots/${locale}/`)
231
+ ),
232
+ tablet: screenshots.tablet?.map(
233
+ (p) => p.replace("/screenshots/", `/screenshots/${locale}/`)
234
+ )
235
+ };
236
+ appStoreLocales[locale] = {
237
+ name: aso.title || "",
238
+ subtitle: aso.subtitle,
239
+ description: generateFullDescription(localeData, metadata),
240
+ keywords: Array.isArray(aso.keywords) ? aso.keywords.join(", ") : aso.keywords,
241
+ promotionalText: void 0,
242
+ bundleId: config.bundleId,
243
+ locale,
244
+ supportUrl: metadata.supportUrl,
245
+ marketingUrl: metadata.marketingUrl,
246
+ privacyPolicyUrl: metadata.privacyUrl,
247
+ screenshots: {
248
+ // 폰 스크린샷을 iphone65로 매핑
249
+ iphone65: localeScreenshots.phone || [],
250
+ // 태블릿 스크린샷을 ipadPro129로 매핑
251
+ ipadPro129: localeScreenshots.tablet
252
+ }
253
+ };
254
+ }
255
+ const appStoreLocaleKeys = Object.keys(appStoreLocales);
256
+ if (appStoreLocaleKeys.length > 0) {
257
+ const hasConfigDefault = isAppStoreLocale(defaultLocale) && Boolean(appStoreLocales[defaultLocale]);
258
+ const resolvedDefault = hasConfigDefault ? defaultLocale : appStoreLocales[DEFAULT_LOCALE] ? DEFAULT_LOCALE : appStoreLocaleKeys[0];
259
+ asoData.appStore = {
260
+ locales: appStoreLocales,
261
+ defaultLocale: resolvedDefault
262
+ };
263
+ }
264
+ }
265
+ const hasGooglePlay = !!asoData.googlePlay;
266
+ const hasAppStore = !!asoData.appStore;
267
+ console.debug(`[loadAsoFromConfig] Result for ${slug}:`);
268
+ console.debug(
269
+ ` - Google Play data: ${hasGooglePlay ? "found" : "not found"}`
270
+ );
271
+ console.debug(` - App Store data: ${hasAppStore ? "found" : "not found"}`);
272
+ if (!hasGooglePlay && !hasAppStore) {
273
+ console.warn(`[loadAsoFromConfig] No ASO data generated for ${slug}`);
274
+ }
275
+ return asoData;
276
+ } catch (error) {
277
+ console.error(
278
+ `[loadAsoFromConfig] Failed to load ASO data from config for ${slug}:`,
279
+ error
280
+ );
281
+ return {};
282
+ }
283
+ }
284
+ function saveAsoToConfig(slug, config) {
285
+ const productsDir = getProductsDir();
286
+ const configPath = path2.join(productsDir, slug, "config.json");
287
+ fs2.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
288
+ }
289
+ function saveAsoToAsoDir(slug, asoData) {
290
+ const rootDir = getPushDataDir();
291
+ if (asoData.googlePlay) {
292
+ const asoPath = path2.join(
293
+ rootDir,
294
+ "products",
295
+ slug,
296
+ "store",
297
+ "google-play",
298
+ "aso-data.json"
299
+ );
300
+ const dir = path2.dirname(asoPath);
301
+ if (!fs2.existsSync(dir)) {
302
+ fs2.mkdirSync(dir, { recursive: true });
303
+ }
304
+ const googlePlayData = asoData.googlePlay;
305
+ const multilingualData = "locales" in googlePlayData ? googlePlayData : {
306
+ locales: {
307
+ [googlePlayData.defaultLanguage || DEFAULT_LOCALE]: googlePlayData
308
+ },
309
+ defaultLocale: googlePlayData.defaultLanguage || DEFAULT_LOCALE
310
+ };
311
+ fs2.writeFileSync(
312
+ asoPath,
313
+ JSON.stringify({ googlePlay: multilingualData }, null, 2) + "\n",
314
+ "utf-8"
315
+ );
316
+ }
317
+ if (asoData.appStore) {
318
+ const asoPath = path2.join(
319
+ rootDir,
320
+ "products",
321
+ slug,
322
+ "store",
323
+ "app-store",
324
+ "aso-data.json"
325
+ );
326
+ const dir = path2.dirname(asoPath);
327
+ if (!fs2.existsSync(dir)) {
328
+ fs2.mkdirSync(dir, { recursive: true });
329
+ }
330
+ const appStoreData = asoData.appStore;
331
+ const multilingualData = "locales" in appStoreData ? appStoreData : {
332
+ locales: {
333
+ [appStoreData.locale || DEFAULT_LOCALE]: appStoreData
334
+ },
335
+ defaultLocale: appStoreData.locale || DEFAULT_LOCALE
336
+ };
337
+ fs2.writeFileSync(
338
+ asoPath,
339
+ JSON.stringify({ appStore: multilingualData }, null, 2) + "\n",
340
+ "utf-8"
341
+ );
342
+ }
343
+ }
344
+
345
+ export {
346
+ getAsoDataDir,
347
+ getPullDataDir,
348
+ getPushDataDir,
349
+ getPublicDir,
350
+ getKeywordResearchDir,
351
+ getProductsDir,
352
+ loadAsoFromConfig,
353
+ saveAsoToConfig,
354
+ saveAsoToAsoDir
355
+ };
package/dist/index.d.ts CHANGED
@@ -40,9 +40,13 @@ declare function getPushDataDir(): string;
40
40
  * Get the public directory path (dataDir/public)
41
41
  */
42
42
  declare function getPublicDir(): string;
43
+ /**
44
+ * Get the keywordResearch directory path (dataDir/.aso/keywordResearch)
45
+ */
46
+ declare function getKeywordResearchDir(): string;
43
47
  /**
44
48
  * Get the products directory path (dataDir/public/products)
45
49
  */
46
50
  declare function getProductsDir(): string;
47
51
 
48
- export { AsoData, ProductConfig, getAsoDataDir, getProductsDir, getPublicDir, getPullDataDir, getPushDataDir, loadAsoFromConfig, saveAsoToAsoDir, saveAsoToConfig };
52
+ export { AsoData, ProductConfig, getAsoDataDir, getKeywordResearchDir, getProductsDir, getPublicDir, getPullDataDir, getPushDataDir, loadAsoFromConfig, saveAsoToAsoDir, saveAsoToConfig };
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  getAsoDataDir,
3
+ getKeywordResearchDir,
3
4
  getProductsDir,
4
5
  getPublicDir,
5
6
  getPullDataDir,
@@ -7,7 +8,7 @@ import {
7
8
  loadAsoFromConfig,
8
9
  saveAsoToAsoDir,
9
10
  saveAsoToConfig
10
- } from "./chunk-FXCHLO7O.js";
11
+ } from "./chunk-W62HB2ZL.js";
11
12
  import {
12
13
  APP_STORE_TO_UNIFIED,
13
14
  DEFAULT_LOCALE,
@@ -51,6 +52,7 @@ export {
51
52
  convertObjectToAppStore,
52
53
  convertObjectToGooglePlay,
53
54
  getAsoDataDir,
55
+ getKeywordResearchDir,
54
56
  getProductsDir,
55
57
  getPublicDir,
56
58
  getPullDataDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pabal-web-mcp",
3
- "version": "1.2.4",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "MCP server for ASO data management with shared types and utilities",
6
6
  "author": "skyu",