pabal-web-mcp 1.2.3 → 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.
@@ -1,21 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- DEFAULT_LOCALE,
4
- appStoreToUnified,
3
+ DEFAULT_APP_SLUG
4
+ } from "../chunk-DLCIXAUB.js";
5
+ import {
6
+ getKeywordResearchDir,
5
7
  getProductsDir,
6
8
  getPublicDir,
7
9
  getPullDataDir,
8
10
  getPushDataDir,
11
+ loadAsoFromConfig,
12
+ saveAsoToAsoDir
13
+ } from "../chunk-W62HB2ZL.js";
14
+ import {
15
+ DEFAULT_LOCALE,
16
+ appStoreToUnified,
9
17
  googlePlayToUnified,
10
18
  isAppStoreLocale,
11
19
  isAppStoreMultilingual,
12
20
  isGooglePlayLocale,
13
21
  isGooglePlayMultilingual,
14
- loadAsoFromConfig,
15
- saveAsoToAsoDir,
16
22
  unifiedToAppStore,
17
23
  unifiedToGooglePlay
18
- } from "../chunk-FDI7WF45.js";
24
+ } from "../chunk-BOWRBVVV.js";
19
25
 
20
26
  // src/bin/mcp-server.ts
21
27
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -1189,7 +1195,14 @@ ${json}
1189
1195
  // src/tools/utils/improve-public/generate-aso-prompt.util.ts
1190
1196
  var FIELD_LIMITS_DOC_PATH2 = "docs/aso/ASO_FIELD_LIMITS.md";
1191
1197
  function generatePrimaryOptimizationPrompt(args) {
1192
- const { slug, category, primaryLocale, localeSections } = args;
1198
+ const {
1199
+ slug,
1200
+ category,
1201
+ primaryLocale,
1202
+ localeSections,
1203
+ keywordResearchByLocale,
1204
+ keywordResearchDirByLocale
1205
+ } = args;
1193
1206
  let prompt = `# ASO Optimization - Stage 1: Primary Locale
1194
1207
 
1195
1208
  `;
@@ -1199,32 +1212,33 @@ function generatePrimaryOptimizationPrompt(args) {
1199
1212
  prompt += `## Task
1200
1213
 
1201
1214
  `;
1202
- 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.
1203
1216
 
1204
1217
  `;
1205
1218
  prompt += `## Step 1: Keyword Research (${primaryLocale})
1206
1219
 
1207
1220
  `;
1208
- prompt += `**Strategies** (apply all 5):
1209
- `;
1210
- prompt += `1. **App Store**: Search top apps in category \u2192 extract keywords from titles/descriptions
1211
- `;
1212
- prompt += `2. **Description-Based**: Use current shortDescription \u2192 find related ASO keywords
1213
- `;
1214
- prompt += `3. **Feature-Based**: Identify 2-3 core features \u2192 search feature-specific keywords
1215
- `;
1216
- 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
+
1217
1226
  `;
1218
- prompt += `5. **Competitor**: Analyze 3+ successful apps \u2192 find common keyword patterns
1227
+ prompt += `Saved research:
1228
+ ${researchSections.join("\n")}
1219
1229
 
1220
1230
  `;
1221
- 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.
1222
1235
 
1223
1236
  `;
1237
+ }
1224
1238
  prompt += `## Step 2: Optimize All Fields (${primaryLocale})
1225
1239
 
1226
1240
  `;
1227
- prompt += `Apply the 10 keywords to ALL fields:
1241
+ prompt += `Apply the selected keywords to ALL fields:
1228
1242
  `;
1229
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)
1230
1244
  `;
@@ -1279,11 +1293,11 @@ function generatePrimaryOptimizationPrompt(args) {
1279
1293
  prompt += `## Output Format
1280
1294
 
1281
1295
  `;
1282
- prompt += `**1. Keyword Research**
1296
+ prompt += `**1. Keyword Research (from saved data)**
1283
1297
  `;
1284
- 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)
1285
1299
  `;
1286
- prompt += ` - Final 10 keywords: [list] with rationale
1300
+ prompt += ` - Rationale: why these 10 were chosen from saved research
1287
1301
 
1288
1302
  `;
1289
1303
  prompt += `**2. Optimized JSON** (complete ${primaryLocale} locale structure)
@@ -1331,6 +1345,8 @@ function generateKeywordLocalizationPrompt(args) {
1331
1345
  targetLocales,
1332
1346
  localeSections,
1333
1347
  optimizedPrimary,
1348
+ keywordResearchByLocale,
1349
+ keywordResearchDirByLocale,
1334
1350
  batchLocales,
1335
1351
  batchIndex,
1336
1352
  totalBatches,
@@ -1374,7 +1390,7 @@ function generateKeywordLocalizationPrompt(args) {
1374
1390
  `;
1375
1391
  prompt += `For EACH target locale in this batch:
1376
1392
  `;
1377
- prompt += `1. Research 10 language-specific keywords
1393
+ prompt += `1. Use SAVED keyword research (see per-locale data below). Do NOT invent keywords.
1378
1394
  `;
1379
1395
  prompt += `2. Replace keywords in translated content (preserve structure/tone/context)
1380
1396
  `;
@@ -1395,18 +1411,22 @@ ${optimizedPrimary}
1395
1411
  prompt += `## Keyword Research (Per Locale)
1396
1412
 
1397
1413
  `;
1398
- prompt += `For EACH locale, perform lightweight keyword research:
1399
- `;
1400
- prompt += `1. **App Store Search**: "[core feature] [lang] \uC571/app" \u2192 top 5 apps
1401
- `;
1402
- prompt += `2. **Competitor Keywords**: Extract keywords from successful apps in that language
1403
- `;
1404
- 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
+ )}
1405
1422
 
1406
1423
  `;
1407
- 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.
1408
1426
 
1409
1427
  `;
1428
+ }
1429
+ });
1410
1430
  prompt += `## Keyword Replacement Strategy
1411
1431
 
1412
1432
  `;
@@ -1487,7 +1507,7 @@ ${optimizedPrimary}
1487
1507
  `;
1488
1508
  prompt += `Process EACH locale in this batch sequentially:
1489
1509
  `;
1490
- prompt += `1. Research 10 keywords for locale
1510
+ prompt += `1. Use saved keyword research (or pause if missing and request keyword-research run)
1491
1511
  `;
1492
1512
  prompt += `2. Replace keywords in ALL fields:
1493
1513
  `;
@@ -1543,11 +1563,11 @@ ${optimizedPrimary}
1543
1563
  prompt += `### Locale [locale-code]:
1544
1564
 
1545
1565
  `;
1546
- prompt += `**1. Keyword Research**
1566
+ prompt += `**1. Keyword Research (saved)**
1547
1567
  `;
1548
- prompt += ` - Query: "[query]" \u2192 Keywords: [list]
1568
+ prompt += ` - Cite file(s) used; list selected top 10 keywords (no new research)
1549
1569
  `;
1550
- prompt += ` - Final 10: [list] with rationale
1570
+ prompt += ` - Rationale: why these were chosen from saved research
1551
1571
 
1552
1572
  `;
1553
1573
  prompt += `**2. Updated JSON** (complete locale structure with keyword replacements)
@@ -1591,6 +1611,92 @@ ${optimizedPrimary}
1591
1611
  return prompt;
1592
1612
  }
1593
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
+
1594
1700
  // src/tools/improve-public.ts
1595
1701
  var FIELD_LIMITS_DOC_PATH3 = "docs/aso/ASO_FIELD_LIMITS.md";
1596
1702
  var toJsonSchema3 = zodToJsonSchema3;
@@ -1707,12 +1813,21 @@ async function handleImprovePublic(input) {
1707
1813
  })
1708
1814
  );
1709
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
+ }
1710
1823
  const baseArgs = {
1711
1824
  slug,
1712
1825
  category,
1713
1826
  primaryLocale,
1714
1827
  targetLocales,
1715
- localeSections
1828
+ localeSections,
1829
+ keywordResearchByLocale,
1830
+ keywordResearchDirByLocale
1716
1831
  };
1717
1832
  if (stage === "1" || stage === "both") {
1718
1833
  const prompt = generatePrimaryOptimizationPrompt(baseArgs);
@@ -1758,6 +1873,8 @@ async function handleImprovePublic(input) {
1758
1873
  primaryLocale: baseArgs.primaryLocale,
1759
1874
  targetLocales: baseArgs.targetLocales,
1760
1875
  localeSections: baseArgs.localeSections,
1876
+ keywordResearchByLocale: baseArgs.keywordResearchByLocale,
1877
+ keywordResearchDirByLocale: baseArgs.keywordResearchDirByLocale,
1761
1878
  optimizedPrimary,
1762
1879
  batchLocales,
1763
1880
  batchIndex: currentBatchIndex,
@@ -1780,13 +1897,13 @@ async function handleImprovePublic(input) {
1780
1897
  }
1781
1898
 
1782
1899
  // src/tools/init-project.ts
1783
- import fs6 from "fs";
1784
- import path6 from "path";
1900
+ import fs7 from "fs";
1901
+ import path7 from "path";
1785
1902
  import { z as z4 } from "zod";
1786
1903
  import { zodToJsonSchema as zodToJsonSchema4 } from "zod-to-json-schema";
1787
1904
  var listSlugDirs = (dir) => {
1788
- if (!fs6.existsSync(dir)) return [];
1789
- 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);
1790
1907
  };
1791
1908
  var initProjectInputSchema = z4.object({
1792
1909
  slug: z4.string().trim().optional().describe(
@@ -1811,7 +1928,7 @@ Steps:
1811
1928
  inputSchema: inputSchema4
1812
1929
  };
1813
1930
  async function handleInitProject(input) {
1814
- const pullDataDir = path6.join(getPullDataDir(), "products");
1931
+ const pullDataDir = path7.join(getPullDataDir(), "products");
1815
1932
  const publicDir = getProductsDir();
1816
1933
  const pullDataSlugs = listSlugDirs(pullDataDir);
1817
1934
  const publicSlugs = listSlugDirs(publicDir);
@@ -1889,14 +2006,14 @@ async function handleInitProject(input) {
1889
2006
  }
1890
2007
 
1891
2008
  // src/tools/create-blog-html.ts
1892
- import fs8 from "fs";
1893
- import path8 from "path";
2009
+ import fs9 from "fs";
2010
+ import path9 from "path";
1894
2011
  import { z as z5 } from "zod";
1895
2012
  import { zodToJsonSchema as zodToJsonSchema5 } from "zod-to-json-schema";
1896
2013
 
1897
2014
  // src/utils/blog.util.ts
1898
- import fs7 from "fs";
1899
- import path7 from "path";
2015
+ import fs8 from "fs";
2016
+ import path8 from "path";
1900
2017
  var DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
1901
2018
  var BLOG_ROOT = "blogs";
1902
2019
  var removeDiacritics = (value) => value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
@@ -1984,13 +2101,13 @@ function resolveTargetLocales(input) {
1984
2101
  return fallback ? [fallback] : [];
1985
2102
  }
1986
2103
  function getBlogOutputPaths(options) {
1987
- const baseDir = path7.join(
2104
+ const baseDir = path8.join(
1988
2105
  options.publicDir,
1989
2106
  BLOG_ROOT,
1990
2107
  options.appSlug,
1991
2108
  options.slug
1992
2109
  );
1993
- const filePath = path7.join(baseDir, `${options.locale}.html`);
2110
+ const filePath = path8.join(baseDir, `${options.locale}.html`);
1994
2111
  const publicBasePath = toPublicBlogBase(options.appSlug, options.slug);
1995
2112
  return { baseDir, filePath, publicBasePath };
1996
2113
  }
@@ -2015,18 +2132,18 @@ function findExistingBlogPosts({
2015
2132
  publicDir,
2016
2133
  limit = 2
2017
2134
  }) {
2018
- const blogAppDir = path7.join(publicDir, BLOG_ROOT, appSlug);
2019
- if (!fs7.existsSync(blogAppDir)) {
2135
+ const blogAppDir = path8.join(publicDir, BLOG_ROOT, appSlug);
2136
+ if (!fs8.existsSync(blogAppDir)) {
2020
2137
  return [];
2021
2138
  }
2022
2139
  const posts = [];
2023
- const subdirs = fs7.readdirSync(blogAppDir, { withFileTypes: true });
2140
+ const subdirs = fs8.readdirSync(blogAppDir, { withFileTypes: true });
2024
2141
  for (const subdir of subdirs) {
2025
2142
  if (!subdir.isDirectory()) continue;
2026
- const localeFile = path7.join(blogAppDir, subdir.name, `${locale}.html`);
2027
- if (!fs7.existsSync(localeFile)) continue;
2143
+ const localeFile = path8.join(blogAppDir, subdir.name, `${locale}.html`);
2144
+ if (!fs8.existsSync(localeFile)) continue;
2028
2145
  try {
2029
- const htmlContent = fs7.readFileSync(localeFile, "utf-8");
2146
+ const htmlContent = fs8.readFileSync(localeFile, "utf-8");
2030
2147
  const { meta, body } = parseBlogHtml(htmlContent);
2031
2148
  if (meta && meta.locale === locale) {
2032
2149
  posts.push({
@@ -2052,9 +2169,6 @@ function findExistingBlogPosts({
2052
2169
  }));
2053
2170
  }
2054
2171
 
2055
- // src/constants/blog.constants.ts
2056
- var DEFAULT_APP_SLUG = "developer";
2057
-
2058
2172
  // src/tools/create-blog-html.ts
2059
2173
  var toJsonSchema4 = zodToJsonSchema5;
2060
2174
  var DATE_REGEX2 = /^\d{4}-\d{2}-\d{2}$/;
@@ -2172,7 +2286,7 @@ async function handleCreateBlogHtml(input) {
2172
2286
  }
2173
2287
  const output = {
2174
2288
  slug,
2175
- baseDir: path8.join(publicDir, "blogs", appSlug, slug),
2289
+ baseDir: path9.join(publicDir, "blogs", appSlug, slug),
2176
2290
  files: [],
2177
2291
  coverImage: coverImage && coverImage.trim().length > 0 ? coverImage.trim() : `/products/${appSlug}/og-image.png`,
2178
2292
  metaByLocale: {}
@@ -2186,7 +2300,7 @@ async function handleCreateBlogHtml(input) {
2186
2300
  })
2187
2301
  );
2188
2302
  const existing = plannedFiles.filter(
2189
- ({ filePath }) => fs8.existsSync(filePath)
2303
+ ({ filePath }) => fs9.existsSync(filePath)
2190
2304
  );
2191
2305
  if (existing.length > 0 && !overwrite) {
2192
2306
  const existingList = existing.map((f) => f.filePath).join("\n- ");
@@ -2195,7 +2309,7 @@ async function handleCreateBlogHtml(input) {
2195
2309
  - ${existingList}`
2196
2310
  );
2197
2311
  }
2198
- fs8.mkdirSync(output.baseDir, { recursive: true });
2312
+ fs9.mkdirSync(output.baseDir, { recursive: true });
2199
2313
  for (const locale of targetLocales) {
2200
2314
  const { filePath } = getBlogOutputPaths({
2201
2315
  appSlug,
@@ -2221,7 +2335,7 @@ async function handleCreateBlogHtml(input) {
2221
2335
  meta,
2222
2336
  content
2223
2337
  });
2224
- fs8.writeFileSync(filePath, html, "utf-8");
2338
+ fs9.writeFileSync(filePath, html, "utf-8");
2225
2339
  output.files.push({ locale, path: filePath });
2226
2340
  }
2227
2341
  const summaryLines = [
@@ -2257,6 +2371,243 @@ Writing style reference for ${locale}: Found ${posts.length} existing post(s) us
2257
2371
  };
2258
2372
  }
2259
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
+
2260
2611
  // src/tools/index.ts
2261
2612
  var tools = [
2262
2613
  {
@@ -2298,6 +2649,14 @@ var tools = [
2298
2649
  zodSchema: createBlogHtmlInputSchema,
2299
2650
  handler: handleCreateBlogHtml,
2300
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"
2301
2660
  }
2302
2661
  ];
2303
2662
  function getToolDefinitions() {
@@ -2306,7 +2665,8 @@ function getToolDefinitions() {
2306
2665
  publicToAsoTool,
2307
2666
  improvePublicTool,
2308
2667
  initProjectTool,
2309
- createBlogHtmlTool
2668
+ createBlogHtmlTool,
2669
+ keywordResearchTool
2310
2670
  ];
2311
2671
  }
2312
2672
  function getToolHandler(name) {