pabal-web-mcp 1.3.7 → 1.3.9
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/dist/bin/mcp-server.js +526 -351
- package/package.json +1 -1
package/dist/bin/mcp-server.js
CHANGED
|
@@ -212,7 +212,7 @@ var asoToPublicTool = {
|
|
|
212
212
|
name: "aso-to-public",
|
|
213
213
|
description: `Converts ASO data from pullData to public/products/[slug]/ structure.
|
|
214
214
|
|
|
215
|
-
**IMPORTANT:**
|
|
215
|
+
**IMPORTANT:** Always use 'search-app' tool first to resolve the exact slug before calling this tool. The user may provide an approximate name, bundleId, or packageName - search-app will find and return the correct slug. Never pass user input directly as slug.
|
|
216
216
|
|
|
217
217
|
This tool:
|
|
218
218
|
1. Loads ASO data from .aso/pullData/products/[slug]/store/ (path from ~/.config/pabal-mcp/config.json dataDir)
|
|
@@ -665,7 +665,7 @@ var publicToAsoTool = {
|
|
|
665
665
|
name: "public-to-aso",
|
|
666
666
|
description: `Prepares ASO data from public/products/[slug]/ to pushData format.
|
|
667
667
|
|
|
668
|
-
**IMPORTANT:**
|
|
668
|
+
**IMPORTANT:** Always use 'search-app' tool first to resolve the exact slug before calling this tool. The user may provide an approximate name, bundleId, or packageName - search-app will find and return the correct slug. Never pass user input directly as slug.
|
|
669
669
|
|
|
670
670
|
This tool:
|
|
671
671
|
1. Loads ASO data from public/products/[slug]/config.json + locales/
|
|
@@ -1727,7 +1727,7 @@ var improvePublicTool = {
|
|
|
1727
1727
|
name: "improve-public",
|
|
1728
1728
|
description: `Optimizes locale JSON in public/products/[slug]/locales for ASO.
|
|
1729
1729
|
|
|
1730
|
-
**IMPORTANT:**
|
|
1730
|
+
**IMPORTANT:** Always use 'search-app' tool first to resolve the exact slug before calling this tool. The user may provide an approximate name, bundleId, or packageName - search-app will find and return the correct slug. Never pass user input directly as slug.
|
|
1731
1731
|
|
|
1732
1732
|
**CRITICAL: Only processes existing locale files. Does NOT create new locale files.**
|
|
1733
1733
|
- Only improves locales that already exist in public/products/[slug]/locales/
|
|
@@ -2378,10 +2378,8 @@ Writing style reference for ${locale}: Found ${posts.length} existing post(s) us
|
|
|
2378
2378
|
// src/tools/keyword-research.ts
|
|
2379
2379
|
import fs11 from "fs";
|
|
2380
2380
|
import path11 from "path";
|
|
2381
|
-
import { z as
|
|
2382
|
-
import { zodToJsonSchema as
|
|
2383
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2384
|
-
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
2381
|
+
import { z as z7 } from "zod";
|
|
2382
|
+
import { zodToJsonSchema as zodToJsonSchema7 } from "zod-to-json-schema";
|
|
2385
2383
|
|
|
2386
2384
|
// src/utils/registered-apps.util.ts
|
|
2387
2385
|
import fs10 from "fs";
|
|
@@ -2415,58 +2413,306 @@ function findRegisteredApp(slug, filePath) {
|
|
|
2415
2413
|
const app = apps.find((a) => a.slug === slug);
|
|
2416
2414
|
return { app, path: usedPath };
|
|
2417
2415
|
}
|
|
2416
|
+
function getSupportedLocalesForSlug(slug, platform, filePath) {
|
|
2417
|
+
const { app, path: usedPath } = findRegisteredApp(slug, filePath);
|
|
2418
|
+
if (!app) return { supportedLocales: [], path: usedPath };
|
|
2419
|
+
if (platform === "ios") {
|
|
2420
|
+
return {
|
|
2421
|
+
supportedLocales: app.appStore?.supportedLocales || [],
|
|
2422
|
+
path: usedPath
|
|
2423
|
+
};
|
|
2424
|
+
}
|
|
2425
|
+
return {
|
|
2426
|
+
supportedLocales: app.googlePlay?.supportedLocales || [],
|
|
2427
|
+
path: usedPath
|
|
2428
|
+
};
|
|
2429
|
+
}
|
|
2418
2430
|
|
|
2419
|
-
// src/tools/
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2431
|
+
// src/tools/search-app.ts
|
|
2432
|
+
import { z as z6 } from "zod";
|
|
2433
|
+
import { zodToJsonSchema as zodToJsonSchema6 } from "zod-to-json-schema";
|
|
2434
|
+
var TOOL_NAME = "search-app";
|
|
2435
|
+
var searchAppInputSchema = z6.object({
|
|
2436
|
+
query: z6.string().trim().optional().describe(
|
|
2437
|
+
"Search term (slug, bundleId, packageName, name). Returns all apps if empty."
|
|
2425
2438
|
),
|
|
2426
|
-
|
|
2427
|
-
|
|
2439
|
+
store: z6.enum(["all", "appStore", "googlePlay"]).default("all").describe("Store filter (default: all)")
|
|
2440
|
+
});
|
|
2441
|
+
var jsonSchema6 = zodToJsonSchema6(searchAppInputSchema, {
|
|
2442
|
+
name: "SearchAppInput",
|
|
2443
|
+
$refStrategy: "none"
|
|
2444
|
+
});
|
|
2445
|
+
var inputSchema6 = jsonSchema6.definitions?.SearchAppInput || jsonSchema6;
|
|
2446
|
+
var searchAppTool = {
|
|
2447
|
+
name: TOOL_NAME,
|
|
2448
|
+
description: `Search registered apps from registered-apps.json.
|
|
2449
|
+
|
|
2450
|
+
- Called without query: Returns all app list
|
|
2451
|
+
- Called with query: Search by slug, bundleId, packageName, name
|
|
2452
|
+
- Use store filter to narrow results to appStore or googlePlay only`,
|
|
2453
|
+
inputSchema: inputSchema6
|
|
2454
|
+
};
|
|
2455
|
+
function matchesQuery(app, query) {
|
|
2456
|
+
const lowerQuery = query.toLowerCase();
|
|
2457
|
+
if (app.slug.toLowerCase().includes(lowerQuery)) return true;
|
|
2458
|
+
if (app.name?.toLowerCase().includes(lowerQuery)) return true;
|
|
2459
|
+
if (app.appStore?.bundleId?.toLowerCase().includes(lowerQuery)) return true;
|
|
2460
|
+
if (app.appStore?.name?.toLowerCase().includes(lowerQuery)) return true;
|
|
2461
|
+
if (app.googlePlay?.packageName?.toLowerCase().includes(lowerQuery))
|
|
2462
|
+
return true;
|
|
2463
|
+
if (app.googlePlay?.name?.toLowerCase().includes(lowerQuery)) return true;
|
|
2464
|
+
return false;
|
|
2465
|
+
}
|
|
2466
|
+
function filterByStore(apps, store) {
|
|
2467
|
+
if (store === "all") return apps;
|
|
2468
|
+
return apps.filter((app) => {
|
|
2469
|
+
if (store === "appStore") return !!app.appStore;
|
|
2470
|
+
if (store === "googlePlay") return !!app.googlePlay;
|
|
2471
|
+
return true;
|
|
2472
|
+
});
|
|
2473
|
+
}
|
|
2474
|
+
function formatAppInfo(app) {
|
|
2475
|
+
const lines = [];
|
|
2476
|
+
lines.push(`\u{1F4F1} **${app.name || app.slug}** (\`${app.slug}\`)`);
|
|
2477
|
+
if (app.appStore) {
|
|
2478
|
+
lines.push(` \u{1F34E} App Store: \`${app.appStore.bundleId || "N/A"}\``);
|
|
2479
|
+
if (app.appStore.appId) {
|
|
2480
|
+
lines.push(` App ID: ${app.appStore.appId}`);
|
|
2481
|
+
}
|
|
2482
|
+
if (app.appStore.name) {
|
|
2483
|
+
lines.push(` Name: ${app.appStore.name}`);
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
if (app.googlePlay) {
|
|
2487
|
+
lines.push(
|
|
2488
|
+
` \u{1F916} Google Play: \`${app.googlePlay.packageName || "N/A"}\``
|
|
2489
|
+
);
|
|
2490
|
+
if (app.googlePlay.name) {
|
|
2491
|
+
lines.push(` Name: ${app.googlePlay.name}`);
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
return lines.join("\n");
|
|
2495
|
+
}
|
|
2496
|
+
async function handleSearchApp(input) {
|
|
2497
|
+
const { query, store = "all" } = input;
|
|
2498
|
+
try {
|
|
2499
|
+
const { apps: allApps, path: configPath } = loadRegisteredApps();
|
|
2500
|
+
let results;
|
|
2501
|
+
if (!query) {
|
|
2502
|
+
results = allApps;
|
|
2503
|
+
} else {
|
|
2504
|
+
const { app: exactMatch } = findRegisteredApp(query);
|
|
2505
|
+
const partialMatches = allApps.filter((app) => matchesQuery(app, query));
|
|
2506
|
+
const seenSlugs = /* @__PURE__ */ new Set();
|
|
2507
|
+
results = [];
|
|
2508
|
+
if (exactMatch && !seenSlugs.has(exactMatch.slug)) {
|
|
2509
|
+
results.push(exactMatch);
|
|
2510
|
+
seenSlugs.add(exactMatch.slug);
|
|
2511
|
+
}
|
|
2512
|
+
for (const app of partialMatches) {
|
|
2513
|
+
if (!seenSlugs.has(app.slug)) {
|
|
2514
|
+
results.push(app);
|
|
2515
|
+
seenSlugs.add(app.slug);
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
results = filterByStore(results, store);
|
|
2520
|
+
if (results.length === 0) {
|
|
2521
|
+
const message = query ? `No apps found matching "${query}".` : "No apps registered.";
|
|
2522
|
+
return {
|
|
2523
|
+
content: [
|
|
2524
|
+
{
|
|
2525
|
+
type: "text",
|
|
2526
|
+
text: `\u274C ${message}
|
|
2527
|
+
|
|
2528
|
+
\u{1F4A1} Register apps in ${configPath}`
|
|
2529
|
+
}
|
|
2530
|
+
],
|
|
2531
|
+
_meta: { apps: [], count: 0, configPath }
|
|
2532
|
+
};
|
|
2533
|
+
}
|
|
2534
|
+
const header = query ? `\u{1F50D} Search results for "${query}": ${results.length} app(s)` : `\u{1F4CB} Registered app list: ${results.length} app(s)`;
|
|
2535
|
+
const appList = results.map(formatAppInfo).join("\n\n");
|
|
2536
|
+
return {
|
|
2537
|
+
content: [
|
|
2538
|
+
{
|
|
2539
|
+
type: "text",
|
|
2540
|
+
text: `${header}
|
|
2541
|
+
|
|
2542
|
+
${appList}
|
|
2543
|
+
|
|
2544
|
+
---
|
|
2545
|
+
Config: ${configPath}`
|
|
2546
|
+
}
|
|
2547
|
+
],
|
|
2548
|
+
_meta: { apps: results, count: results.length, configPath }
|
|
2549
|
+
};
|
|
2550
|
+
} catch (error) {
|
|
2551
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2552
|
+
return {
|
|
2553
|
+
content: [
|
|
2554
|
+
{
|
|
2555
|
+
type: "text",
|
|
2556
|
+
text: `\u274C App search failed: ${message}`
|
|
2557
|
+
}
|
|
2558
|
+
]
|
|
2559
|
+
};
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
// src/tools/keyword-research.ts
|
|
2564
|
+
var TOOL_NAME2 = "keyword-research";
|
|
2565
|
+
var keywordResearchInputSchema = z7.object({
|
|
2566
|
+
slug: z7.string().trim().describe("Product slug"),
|
|
2567
|
+
locale: z7.string().trim().describe("Locale code (e.g., en-US, ko-KR). Used for storage under .aso/keywordResearch/products/[slug]/locales/."),
|
|
2568
|
+
platform: z7.enum(["ios", "android"]).default("ios").describe("Store to target ('ios' or 'android'). Run separately per platform."),
|
|
2569
|
+
country: z7.string().length(2).optional().describe(
|
|
2428
2570
|
"Two-letter store country code. If omitted, derived from locale region (e.g., ko-KR -> kr), else 'us'."
|
|
2429
2571
|
),
|
|
2430
|
-
seedKeywords:
|
|
2431
|
-
competitorApps:
|
|
2432
|
-
|
|
2433
|
-
appId:
|
|
2434
|
-
platform:
|
|
2572
|
+
seedKeywords: z7.array(z7.string().trim()).default([]).describe("Seed keywords to start from."),
|
|
2573
|
+
competitorApps: z7.array(
|
|
2574
|
+
z7.object({
|
|
2575
|
+
appId: z7.string().trim().describe("App ID (package name or iOS ID/bundle)"),
|
|
2576
|
+
platform: z7.enum(["ios", "android"])
|
|
2435
2577
|
})
|
|
2436
|
-
).default([]),
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2578
|
+
).default([]).describe("Known competitor apps to probe."),
|
|
2579
|
+
filename: z7.string().trim().optional().describe("Override output filename. Defaults to keyword-research-[platform]-[country].json"),
|
|
2580
|
+
writeTemplate: z7.boolean().default(false).describe("If true, write a JSON template at the output path."),
|
|
2581
|
+
researchData: z7.string().trim().optional().describe(
|
|
2582
|
+
"Optional JSON string with research results (e.g., from mcp-appstore tools). If provided, saves it to the output path."
|
|
2583
|
+
),
|
|
2584
|
+
researchDataPath: z7.string().trim().optional().describe(
|
|
2585
|
+
"Optional path to a JSON file containing research results. If set, file content is saved to the output path (preferred to avoid escape errors)."
|
|
2586
|
+
)
|
|
2440
2587
|
});
|
|
2441
|
-
var
|
|
2588
|
+
var jsonSchema7 = zodToJsonSchema7(keywordResearchInputSchema, {
|
|
2442
2589
|
name: "KeywordResearchInput",
|
|
2443
2590
|
$refStrategy: "none"
|
|
2444
2591
|
});
|
|
2445
|
-
var
|
|
2592
|
+
var inputSchema7 = jsonSchema7.definitions?.KeywordResearchInput || jsonSchema7;
|
|
2446
2593
|
var keywordResearchTool = {
|
|
2447
|
-
name:
|
|
2448
|
-
description:
|
|
2449
|
-
|
|
2594
|
+
name: TOOL_NAME2,
|
|
2595
|
+
description: `Prep + persist keyword research ahead of improve-public using mcp-appstore outputs.
|
|
2596
|
+
|
|
2597
|
+
**IMPORTANT:** Always use 'search-app' tool first to resolve the exact slug before calling this tool. The user may provide an approximate name, bundleId, or packageName - search-app will find and return the correct slug. Never pass user input directly as slug.
|
|
2598
|
+
|
|
2599
|
+
**Locale coverage:** If the product ships multiple locales, run this tool SEPARATELY for EVERY locale (including non-primary markets). Do NOT rely on "template-only" coverage for secondary locales\u2014produce a full keyword research file per locale.
|
|
2600
|
+
|
|
2601
|
+
**Platform coverage:** Use search-app results to confirm supported platforms/locales (App Store + Google Play). Run this tool for EVERY supported platform/locale combination\u2014ios + android runs are separate.
|
|
2602
|
+
|
|
2603
|
+
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.`,
|
|
2604
|
+
inputSchema: inputSchema7
|
|
2450
2605
|
};
|
|
2606
|
+
function buildTemplate({
|
|
2607
|
+
slug,
|
|
2608
|
+
locale,
|
|
2609
|
+
platform,
|
|
2610
|
+
country,
|
|
2611
|
+
seedKeywords,
|
|
2612
|
+
competitorApps
|
|
2613
|
+
}) {
|
|
2614
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
2615
|
+
return {
|
|
2616
|
+
meta: {
|
|
2617
|
+
slug,
|
|
2618
|
+
locale,
|
|
2619
|
+
platform,
|
|
2620
|
+
country,
|
|
2621
|
+
seedKeywords,
|
|
2622
|
+
competitorApps,
|
|
2623
|
+
source: "mcp-appstore",
|
|
2624
|
+
updatedAt: timestamp
|
|
2625
|
+
},
|
|
2626
|
+
plan: {
|
|
2627
|
+
steps: [
|
|
2628
|
+
"1. SETUP: Start mcp-appstore server (node server.js in external-tools/mcp-appstore).",
|
|
2629
|
+
"2. APP IDENTITY: get_app_details(appId) \u2192 confirm exact app name, category, current keywords, and store listing quality.",
|
|
2630
|
+
"3. COMPETITOR DISCOVERY: search_app(term=seed, num=15) + get_similar_apps(appId=your app, num=20) \u2192 identify 5-10 direct competitors (same category, similar size) + 3-5 aspirational competitors (top performers).",
|
|
2631
|
+
"4. COMPETITOR KEYWORD MINING: For top 5 competitors, run suggest_keywords_by_apps \u2192 extract keywords they rank for but you don't.",
|
|
2632
|
+
"5. KEYWORD EXPANSION (run ALL, num=30 each): suggest_keywords_by_seeds (your app name + core features), by_category (your primary category), by_similarity (semantic variations), by_competition (gap analysis), by_search (autocomplete/trending).",
|
|
2633
|
+
"6. KEYWORD SCORING: get_keyword_scores for ALL candidates (50-100 keywords) \u2192 filter by: traffic \u226510, difficulty \u226470, relevance to your app.",
|
|
2634
|
+
"7. REVIEW INTELLIGENCE: analyze_reviews(num=200) + fetch_reviews(num=100) on top 3 competitors \u2192 extract: user pain points, feature requests, emotional language, native phrases users actually use.",
|
|
2635
|
+
"8. KEYWORD CATEGORIZATION: Group into tiers - Tier1 (3-5): high traffic (\u22651000), high relevance, moderate difficulty (\u226450); Tier2 (5-7): medium traffic (100-1000), exact feature match; Tier3 (5-8): longtail (<100 traffic), very low difficulty (\u226430), high conversion intent.",
|
|
2636
|
+
"9. LOCALIZATION CHECK: Verify keywords are natural in target locale - avoid direct translations, prefer native expressions found in reviews.",
|
|
2637
|
+
"10. GAP ANALYSIS: Compare your current keywords vs competitor keywords \u2192 identify missed opportunities and over-saturated terms to avoid.",
|
|
2638
|
+
"11. VALIDATION: For final 15-20 keywords, ensure each has: score data, clear user intent, natural locale fit, and specific rationale for inclusion.",
|
|
2639
|
+
"Keep rationale/nextActions in English by default unless you intentionally localize them."
|
|
2640
|
+
],
|
|
2641
|
+
selectionCriteria: {
|
|
2642
|
+
tier1_core: "High traffic (\u22651000), relevance score \u22650.8, difficulty \u226450, brand-safe",
|
|
2643
|
+
tier2_feature: "Medium traffic (100-1000), exact feature/benefit match, difficulty \u226460",
|
|
2644
|
+
tier3_longtail: "Low traffic (<100), very low difficulty (\u226430), high purchase/download intent phrases",
|
|
2645
|
+
avoid: "Generic terms (difficulty \u226580), irrelevant categories, competitor brand names, terms with no search volume"
|
|
2646
|
+
},
|
|
2647
|
+
qualityChecks: [
|
|
2648
|
+
"Each keyword has traffic + difficulty scores (no gaps)",
|
|
2649
|
+
"Mix of 3 tiers represented (not all longtail, not all high-competition)",
|
|
2650
|
+
"Keywords validated against actual user language from reviews",
|
|
2651
|
+
"No duplicate semantic meanings (e.g., 'photo edit' and 'edit photo')",
|
|
2652
|
+
"Locale-appropriate phrasing verified"
|
|
2653
|
+
],
|
|
2654
|
+
note: "Run per platform/country. Target 15-20 keywords per locale with clear tier distribution. Save ALL raw data for audit trail."
|
|
2655
|
+
},
|
|
2656
|
+
data: {
|
|
2657
|
+
raw: {
|
|
2658
|
+
searchApp: [],
|
|
2659
|
+
getAppDetails: [],
|
|
2660
|
+
similarApps: [],
|
|
2661
|
+
keywordSuggestions: {
|
|
2662
|
+
bySeeds: [],
|
|
2663
|
+
byCategory: [],
|
|
2664
|
+
bySimilarity: [],
|
|
2665
|
+
byCompetition: [],
|
|
2666
|
+
bySearchHints: [],
|
|
2667
|
+
byApps: []
|
|
2668
|
+
},
|
|
2669
|
+
keywordScores: [],
|
|
2670
|
+
reviewsAnalysis: [],
|
|
2671
|
+
reviewsRaw: []
|
|
2672
|
+
},
|
|
2673
|
+
summary: {
|
|
2674
|
+
recommendedKeywords: [],
|
|
2675
|
+
keywordsByTier: {
|
|
2676
|
+
tier1_core: [],
|
|
2677
|
+
tier2_feature: [],
|
|
2678
|
+
tier3_longtail: []
|
|
2679
|
+
},
|
|
2680
|
+
competitorInsights: {
|
|
2681
|
+
topCompetitors: [],
|
|
2682
|
+
keywordGaps: [],
|
|
2683
|
+
userLanguagePatterns: []
|
|
2684
|
+
},
|
|
2685
|
+
rationale: "",
|
|
2686
|
+
confidence: {
|
|
2687
|
+
dataQuality: "",
|
|
2688
|
+
localeRelevance: "",
|
|
2689
|
+
competitivePosition: ""
|
|
2690
|
+
},
|
|
2691
|
+
nextActions: [
|
|
2692
|
+
"Feed tiered keywords into improve-public Stage 1 (prioritize Tier1 for title, Tier2-3 for keyword field)",
|
|
2693
|
+
"Monitor keyword rankings post-update",
|
|
2694
|
+
"Re-run research quarterly or after major competitor changes"
|
|
2695
|
+
]
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
};
|
|
2699
|
+
}
|
|
2700
|
+
function saveJsonFile({
|
|
2701
|
+
researchDir,
|
|
2702
|
+
fileName,
|
|
2703
|
+
payload
|
|
2704
|
+
}) {
|
|
2705
|
+
fs11.mkdirSync(researchDir, { recursive: true });
|
|
2706
|
+
const outputPath = path11.join(researchDir, fileName);
|
|
2707
|
+
fs11.writeFileSync(outputPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
|
|
2708
|
+
return outputPath;
|
|
2709
|
+
}
|
|
2451
2710
|
function normalizeKeywords(raw) {
|
|
2452
2711
|
if (!raw) return [];
|
|
2453
2712
|
if (Array.isArray(raw)) {
|
|
2454
|
-
return raw.map((k) => k.trim()).filter(
|
|
2455
|
-
}
|
|
2456
|
-
return raw.split(",").map((k) => k.trim()).filter(Boolean);
|
|
2457
|
-
}
|
|
2458
|
-
async function callToolJSON(client, name, args) {
|
|
2459
|
-
const result = await client.callTool({ name, arguments: args });
|
|
2460
|
-
const text = result.content?.[0]?.text;
|
|
2461
|
-
if (!text) return null;
|
|
2462
|
-
try {
|
|
2463
|
-
return JSON.parse(text);
|
|
2464
|
-
} catch {
|
|
2465
|
-
return text;
|
|
2713
|
+
return raw.map((k) => k.trim()).filter((k) => k.length > 0);
|
|
2466
2714
|
}
|
|
2467
|
-
|
|
2468
|
-
function ensureDir(dir) {
|
|
2469
|
-
fs11.mkdirSync(dir, { recursive: true });
|
|
2715
|
+
return raw.split(",").map((k) => k.trim()).filter((k) => k.length > 0);
|
|
2470
2716
|
}
|
|
2471
2717
|
async function handleKeywordResearch(input) {
|
|
2472
2718
|
const {
|
|
@@ -2476,28 +2722,44 @@ async function handleKeywordResearch(input) {
|
|
|
2476
2722
|
country,
|
|
2477
2723
|
seedKeywords = [],
|
|
2478
2724
|
competitorApps = [],
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2725
|
+
filename,
|
|
2726
|
+
writeTemplate = false,
|
|
2727
|
+
researchData,
|
|
2728
|
+
researchDataPath
|
|
2482
2729
|
} = input;
|
|
2730
|
+
const searchResult = await handleSearchApp({ query: slug, store: "all" });
|
|
2731
|
+
const registeredApps = searchResult._meta?.apps || [];
|
|
2732
|
+
const registeredApp = registeredApps.length > 0 ? registeredApps[0] : void 0;
|
|
2483
2733
|
const { config, locales } = loadProductLocales(slug);
|
|
2484
2734
|
const primaryLocale = resolvePrimaryLocale(config, locales);
|
|
2735
|
+
const productLocales = Object.keys(locales);
|
|
2736
|
+
const remainingLocales = productLocales.filter((loc) => loc !== locale);
|
|
2485
2737
|
const primaryLocaleData = locales[primaryLocale];
|
|
2486
|
-
const {
|
|
2487
|
-
const
|
|
2738
|
+
const { supportedLocales, path: supportedPath } = getSupportedLocalesForSlug(slug, platform);
|
|
2739
|
+
const appStoreLocales = registeredApp?.appStore?.supportedLocales || [];
|
|
2740
|
+
const googlePlayLocales = registeredApp?.googlePlay?.supportedLocales || [];
|
|
2741
|
+
const declaredPlatforms = [
|
|
2742
|
+
registeredApp?.appStore ? "ios" : null,
|
|
2743
|
+
registeredApp?.googlePlay ? "android" : null
|
|
2744
|
+
].filter(Boolean);
|
|
2488
2745
|
const autoSeeds = [];
|
|
2489
|
-
|
|
2746
|
+
const autoCompetitors = [];
|
|
2747
|
+
if (primaryLocaleData?.aso?.title) {
|
|
2748
|
+
autoSeeds.push(primaryLocaleData.aso.title);
|
|
2749
|
+
}
|
|
2490
2750
|
const parsedKeywords = normalizeKeywords(primaryLocaleData?.aso?.keywords);
|
|
2491
2751
|
autoSeeds.push(...parsedKeywords.slice(0, 5));
|
|
2492
2752
|
if (config?.name) autoSeeds.push(config.name);
|
|
2493
2753
|
if (config?.tagline) autoSeeds.push(config.tagline);
|
|
2494
|
-
if (registeredApp?.name) autoSeeds.push(registeredApp.name);
|
|
2495
|
-
if (
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2754
|
+
if (!config?.name && registeredApp?.name) autoSeeds.push(registeredApp.name);
|
|
2755
|
+
if (!primaryLocaleData?.aso?.title) {
|
|
2756
|
+
if (platform === "ios" && registeredApp?.appStore?.name) {
|
|
2757
|
+
autoSeeds.push(registeredApp.appStore.name);
|
|
2758
|
+
}
|
|
2759
|
+
if (platform === "android" && registeredApp?.googlePlay?.name) {
|
|
2760
|
+
autoSeeds.push(registeredApp.googlePlay.name);
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2501
2763
|
if (platform === "ios") {
|
|
2502
2764
|
if (config?.appStoreAppId) {
|
|
2503
2765
|
autoCompetitors.push({ appId: String(config.appStoreAppId), platform });
|
|
@@ -2514,320 +2776,224 @@ async function handleKeywordResearch(input) {
|
|
|
2514
2776
|
platform
|
|
2515
2777
|
});
|
|
2516
2778
|
}
|
|
2517
|
-
} else {
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
});
|
|
2525
|
-
}
|
|
2526
|
-
}
|
|
2527
|
-
const competitors = Array.from(
|
|
2528
|
-
new Map(
|
|
2529
|
-
[...competitorApps, ...autoCompetitors].map((c) => [
|
|
2530
|
-
`${c.platform}:${c.appId}`,
|
|
2531
|
-
c
|
|
2532
|
-
])
|
|
2533
|
-
).values()
|
|
2534
|
-
).slice(0, 5);
|
|
2535
|
-
const cwd = serverCwd || path11.resolve(process.cwd(), "external-tools", "mcp-appstore");
|
|
2536
|
-
const cmd = serverCommand?.[0] || "npm";
|
|
2537
|
-
const args = serverCommand?.length ? serverCommand.slice(1) : ["start"];
|
|
2538
|
-
if (!fs11.existsSync(cwd)) {
|
|
2539
|
-
throw new Error(
|
|
2540
|
-
`mcp-appstore cwd not found: ${cwd}. Set 'serverCwd' to the absolute path (e.g., /Users/you/pabal-web-mcp/external-tools/mcp-appstore).`
|
|
2541
|
-
);
|
|
2542
|
-
}
|
|
2543
|
-
const serverJsPath = path11.join(cwd, "server.js");
|
|
2544
|
-
const usingNodeServer = cmd === "node" || args.includes("server.js");
|
|
2545
|
-
if (usingNodeServer && !fs11.existsSync(serverJsPath)) {
|
|
2546
|
-
throw new Error(
|
|
2547
|
-
`mcp-appstore server.js not found under ${cwd}. Set 'serverCommand' (e.g., ["npm","start"] or ["node","/abs/path/server.js"]).`
|
|
2548
|
-
);
|
|
2779
|
+
} else if (platform === "android" && config?.packageName) {
|
|
2780
|
+
autoCompetitors.push({ appId: config.packageName, platform });
|
|
2781
|
+
} else if (platform === "android" && registeredApp?.googlePlay?.packageName) {
|
|
2782
|
+
autoCompetitors.push({
|
|
2783
|
+
appId: registeredApp.googlePlay.packageName,
|
|
2784
|
+
platform
|
|
2785
|
+
});
|
|
2549
2786
|
}
|
|
2550
|
-
const
|
|
2551
|
-
const
|
|
2552
|
-
|
|
2553
|
-
|
|
2787
|
+
const resolvedSeeds = seedKeywords.length > 0 ? seedKeywords : Array.from(new Set(autoSeeds));
|
|
2788
|
+
const resolvedCompetitors = competitorApps.length > 0 ? competitorApps : autoCompetitors;
|
|
2789
|
+
const resolvedCountry = country || (locale?.includes("-") ? locale.split("-")[1].toLowerCase() : "us");
|
|
2790
|
+
const researchDir = path11.join(
|
|
2791
|
+
getKeywordResearchDir(),
|
|
2792
|
+
"products",
|
|
2793
|
+
slug,
|
|
2794
|
+
"locales",
|
|
2795
|
+
locale
|
|
2554
2796
|
);
|
|
2555
|
-
|
|
2556
|
-
const
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2797
|
+
const defaultFileName = `keyword-research-${platform}-${resolvedCountry}.json`;
|
|
2798
|
+
const fileName = filename || defaultFileName;
|
|
2799
|
+
let outputPath = path11.join(researchDir, fileName);
|
|
2800
|
+
let fileAction;
|
|
2801
|
+
const parseJsonWithContext = (text) => {
|
|
2802
|
+
try {
|
|
2803
|
+
return JSON.parse(text);
|
|
2804
|
+
} catch (err) {
|
|
2805
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2806
|
+
const match = /position (\d+)/i.exec(message) || /column (\d+)/i.exec(message) || /char (\d+)/i.exec(message);
|
|
2807
|
+
if (match) {
|
|
2808
|
+
const pos = Number(match[1]);
|
|
2809
|
+
const start = Math.max(0, pos - 40);
|
|
2810
|
+
const end = Math.min(text.length, pos + 40);
|
|
2811
|
+
const context = text.slice(start, end);
|
|
2812
|
+
throw new Error(
|
|
2813
|
+
`Failed to parse researchData JSON: ${message}
|
|
2814
|
+
Context around ${pos}: ${context}`
|
|
2815
|
+
);
|
|
2816
|
+
}
|
|
2817
|
+
throw new Error(`Failed to parse researchData JSON: ${message}`);
|
|
2818
|
+
}
|
|
2572
2819
|
};
|
|
2573
|
-
const
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2820
|
+
const loadResearchDataFromPath = (p) => {
|
|
2821
|
+
if (!fs11.existsSync(p)) {
|
|
2822
|
+
throw new Error(`researchDataPath not found: ${p}`);
|
|
2823
|
+
}
|
|
2824
|
+
const raw = fs11.readFileSync(p, "utf-8");
|
|
2825
|
+
return parseJsonWithContext(raw);
|
|
2826
|
+
};
|
|
2827
|
+
if (writeTemplate || researchData) {
|
|
2828
|
+
const payload = researchData ? parseJsonWithContext(researchData) : researchDataPath ? loadResearchDataFromPath(researchDataPath) : buildTemplate({
|
|
2829
|
+
slug,
|
|
2830
|
+
locale,
|
|
2577
2831
|
platform,
|
|
2578
|
-
|
|
2579
|
-
|
|
2832
|
+
country: resolvedCountry,
|
|
2833
|
+
seedKeywords: resolvedSeeds,
|
|
2834
|
+
competitorApps: resolvedCompetitors
|
|
2580
2835
|
});
|
|
2581
|
-
|
|
2582
|
-
|
|
2836
|
+
outputPath = saveJsonFile({ researchDir, fileName, payload });
|
|
2837
|
+
fileAction = researchData ? "Saved provided researchData" : "Wrote template";
|
|
2838
|
+
}
|
|
2839
|
+
const templatePreview = JSON.stringify(
|
|
2840
|
+
buildTemplate({
|
|
2841
|
+
slug,
|
|
2842
|
+
locale,
|
|
2583
2843
|
platform,
|
|
2584
2844
|
country: resolvedCountry,
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2845
|
+
seedKeywords: resolvedSeeds,
|
|
2846
|
+
competitorApps: resolvedCompetitors
|
|
2847
|
+
}),
|
|
2848
|
+
null,
|
|
2849
|
+
2
|
|
2850
|
+
);
|
|
2851
|
+
const lines = [];
|
|
2852
|
+
lines.push(`# Keyword research plan (${slug})`);
|
|
2853
|
+
lines.push(`Locale: ${locale} | Platform: ${platform} | Country: ${resolvedCountry}`);
|
|
2854
|
+
lines.push(`Primary locale detected: ${primaryLocale}`);
|
|
2855
|
+
if (declaredPlatforms.length > 0) {
|
|
2856
|
+
lines.push(`Supported platforms (search-app): ${declaredPlatforms.join(", ")}`);
|
|
2857
|
+
} else {
|
|
2858
|
+
lines.push("Supported platforms (search-app): none detected\u2014update registered-apps.json");
|
|
2592
2859
|
}
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
const res = await callToolJSON(client, "get_app_details", {
|
|
2596
|
-
appId: comp.appId,
|
|
2597
|
-
platform: comp.platform,
|
|
2598
|
-
country: resolvedCountry
|
|
2599
|
-
});
|
|
2600
|
-
raw.getAppDetails.push(res);
|
|
2860
|
+
if (appStoreLocales.length > 0) {
|
|
2861
|
+
lines.push(`Declared App Store locales: ${appStoreLocales.join(", ")}`);
|
|
2601
2862
|
}
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
const res = await callToolJSON(client, "get_similar_apps", {
|
|
2605
|
-
appId: firstCompId,
|
|
2606
|
-
platform,
|
|
2607
|
-
num: 10,
|
|
2608
|
-
country: resolvedCountry
|
|
2609
|
-
});
|
|
2610
|
-
raw.similarApps = res?.similarApps || res || [];
|
|
2863
|
+
if (googlePlayLocales.length > 0) {
|
|
2864
|
+
lines.push(`Declared Google Play locales: ${googlePlayLocales.join(", ")}`);
|
|
2611
2865
|
}
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
firstComp ? {
|
|
2628
|
-
appId: firstComp,
|
|
2629
|
-
platform,
|
|
2630
|
-
num: numSuggestions,
|
|
2631
|
-
country: resolvedCountry
|
|
2632
|
-
} : null,
|
|
2633
|
-
"byCategory"
|
|
2634
|
-
],
|
|
2635
|
-
[
|
|
2636
|
-
"suggest_keywords_by_similarity",
|
|
2637
|
-
firstComp ? {
|
|
2638
|
-
appId: firstComp,
|
|
2639
|
-
platform,
|
|
2640
|
-
num: numSuggestions,
|
|
2641
|
-
country: resolvedCountry
|
|
2642
|
-
} : null,
|
|
2643
|
-
"bySimilarity"
|
|
2644
|
-
],
|
|
2645
|
-
[
|
|
2646
|
-
"suggest_keywords_by_competition",
|
|
2647
|
-
firstComp ? {
|
|
2648
|
-
appId: firstComp,
|
|
2649
|
-
platform,
|
|
2650
|
-
num: numSuggestions,
|
|
2651
|
-
country: resolvedCountry
|
|
2652
|
-
} : null,
|
|
2653
|
-
"byCompetition"
|
|
2654
|
-
],
|
|
2655
|
-
[
|
|
2656
|
-
"suggest_keywords_by_search",
|
|
2657
|
-
{
|
|
2658
|
-
keywords: seeds.slice(0, 5),
|
|
2659
|
-
platform,
|
|
2660
|
-
num: numSuggestions,
|
|
2661
|
-
country: resolvedCountry
|
|
2662
|
-
},
|
|
2663
|
-
"bySearchHints"
|
|
2664
|
-
],
|
|
2665
|
-
[
|
|
2666
|
-
"suggest_keywords_by_apps",
|
|
2667
|
-
appsList.length ? {
|
|
2668
|
-
apps: appsList,
|
|
2669
|
-
platform,
|
|
2670
|
-
num: numSuggestions,
|
|
2671
|
-
country: resolvedCountry
|
|
2672
|
-
} : null,
|
|
2673
|
-
"byApps"
|
|
2674
|
-
]
|
|
2675
|
-
];
|
|
2676
|
-
for (const [tool, args2, key] of suggestCalls) {
|
|
2677
|
-
if (!args2) continue;
|
|
2678
|
-
const res = await callToolJSON(client, tool, args2);
|
|
2679
|
-
const suggestions = res?.suggestions || res || [];
|
|
2680
|
-
raw.keywordSuggestions[key] = suggestions;
|
|
2681
|
-
}
|
|
2682
|
-
for (const comp of competitors.slice(0, 2)) {
|
|
2683
|
-
const analysis = await callToolJSON(client, "analyze_reviews", {
|
|
2684
|
-
appId: comp.appId,
|
|
2685
|
-
platform: comp.platform,
|
|
2686
|
-
country: resolvedCountry,
|
|
2687
|
-
num: 150
|
|
2688
|
-
});
|
|
2689
|
-
raw.reviewsAnalysis.push(analysis);
|
|
2690
|
-
const rawReviews = await callToolJSON(client, "fetch_reviews", {
|
|
2691
|
-
appId: comp.appId,
|
|
2692
|
-
platform: comp.platform,
|
|
2693
|
-
country: resolvedCountry,
|
|
2694
|
-
num: 80
|
|
2695
|
-
});
|
|
2696
|
-
raw.reviewsRaw.push(rawReviews);
|
|
2866
|
+
if (supportedLocales.length > 0) {
|
|
2867
|
+
lines.push(
|
|
2868
|
+
`Registered supported locales (${platform}): ${supportedLocales.join(
|
|
2869
|
+
", "
|
|
2870
|
+
)} (source: ${supportedPath})`
|
|
2871
|
+
);
|
|
2872
|
+
if (!supportedLocales.includes(locale)) {
|
|
2873
|
+
lines.push(
|
|
2874
|
+
`WARNING: locale ${locale} not in registered supported locales. Confirm this locale or update registered-apps.json.`
|
|
2875
|
+
);
|
|
2876
|
+
}
|
|
2877
|
+
} else {
|
|
2878
|
+
lines.push(
|
|
2879
|
+
`Registered supported locales not found for ${platform} (checked: ${supportedPath}).`
|
|
2880
|
+
);
|
|
2697
2881
|
}
|
|
2698
|
-
if (
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
platform,
|
|
2710
|
-
country: resolvedCountry,
|
|
2711
|
-
num: 80
|
|
2712
|
-
});
|
|
2713
|
-
raw.reviewsRaw.push(rawReviews);
|
|
2714
|
-
}
|
|
2715
|
-
const candidateSet = /* @__PURE__ */ new Set();
|
|
2716
|
-
const addCandidates = (arr) => {
|
|
2717
|
-
if (!arr) return;
|
|
2718
|
-
if (Array.isArray(arr)) {
|
|
2719
|
-
arr.forEach((v) => {
|
|
2720
|
-
if (typeof v === "string") candidateSet.add(v);
|
|
2721
|
-
if (v?.keyword) candidateSet.add(String(v.keyword));
|
|
2722
|
-
if (v?.suggestions) addCandidates(v.suggestions);
|
|
2723
|
-
});
|
|
2882
|
+
if (productLocales.length > 0) {
|
|
2883
|
+
lines.push(
|
|
2884
|
+
`Existing product locales (${productLocales.length}): ${productLocales.join(", ")}`
|
|
2885
|
+
);
|
|
2886
|
+
lines.push(
|
|
2887
|
+
"MANDATORY: Run FULL keyword research (mcp-appstore workflow) for EVERY locale above\u2014no template-only coverage for secondary markets."
|
|
2888
|
+
);
|
|
2889
|
+
if (remainingLocales.length > 0) {
|
|
2890
|
+
lines.push(
|
|
2891
|
+
`After finishing ${locale}, immediately queue runs for: ${remainingLocales.join(", ")}`
|
|
2892
|
+
);
|
|
2724
2893
|
}
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
const freq = ra?.analysis?.keywordFrequency || ra?.keywordFrequency;
|
|
2730
|
-
if (freq && typeof freq === "object") {
|
|
2731
|
-
addCandidates(Object.keys(freq));
|
|
2894
|
+
if (declaredPlatforms.length > 1) {
|
|
2895
|
+
lines.push(
|
|
2896
|
+
"Also run separate FULL keyword research for each supported platform (e.g., ios + android) across all locales."
|
|
2897
|
+
);
|
|
2732
2898
|
}
|
|
2733
|
-
});
|
|
2734
|
-
const candidates = Array.from(candidateSet).slice(0, 30);
|
|
2735
|
-
for (const kw of candidates) {
|
|
2736
|
-
const res = await callToolJSON(client, "get_keyword_scores", {
|
|
2737
|
-
keyword: kw,
|
|
2738
|
-
platform,
|
|
2739
|
-
country: resolvedCountry
|
|
2740
|
-
});
|
|
2741
|
-
raw.keywordScores.push(res);
|
|
2742
2899
|
}
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
)
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
)
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
nextActions: nextActionsText
|
|
2776
|
-
};
|
|
2777
|
-
await client.close();
|
|
2778
|
-
const researchDir = path11.join(
|
|
2779
|
-
getKeywordResearchDir(),
|
|
2780
|
-
"products",
|
|
2781
|
-
slug,
|
|
2782
|
-
"locales",
|
|
2783
|
-
locale
|
|
2900
|
+
lines.push(
|
|
2901
|
+
`Seeds: ${resolvedSeeds.length > 0 ? resolvedSeeds.join(", ") : "(none set; add seedKeywords or ensure ASO keywords/title exist)"}`
|
|
2902
|
+
);
|
|
2903
|
+
lines.push(
|
|
2904
|
+
`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)"}`
|
|
2905
|
+
);
|
|
2906
|
+
lines.push("");
|
|
2907
|
+
lines.push("## Research Workflow (mcp-appstore)");
|
|
2908
|
+
lines.push("");
|
|
2909
|
+
lines.push("### Phase 1: Setup & Discovery");
|
|
2910
|
+
lines.push(
|
|
2911
|
+
`1) Start mcp-appstore server: node server.js (cwd: external-tools/mcp-appstore)`
|
|
2912
|
+
);
|
|
2913
|
+
lines.push(
|
|
2914
|
+
`2) get_app_details(appId) \u2192 confirm app identity, category, current metadata`
|
|
2915
|
+
);
|
|
2916
|
+
lines.push(
|
|
2917
|
+
`3) search_app(term=seed, num=15, platform=${platform}, country=${resolvedCountry}) \u2192 find direct competitors`
|
|
2918
|
+
);
|
|
2919
|
+
lines.push(
|
|
2920
|
+
`4) get_similar_apps(appId=your app, num=20) \u2192 discover related apps in your space`
|
|
2921
|
+
);
|
|
2922
|
+
lines.push("");
|
|
2923
|
+
lines.push("### Phase 2: Keyword Mining (run ALL of these)");
|
|
2924
|
+
lines.push(
|
|
2925
|
+
`5) suggest_keywords_by_apps(apps=[top 5 competitors]) \u2192 steal competitor keywords`
|
|
2926
|
+
);
|
|
2927
|
+
lines.push(
|
|
2928
|
+
`6) suggest_keywords_by_seeds(seeds=[app name, core features], num=30)`
|
|
2929
|
+
);
|
|
2930
|
+
lines.push(
|
|
2931
|
+
`7) suggest_keywords_by_category(category=your primary category, num=30)`
|
|
2784
2932
|
);
|
|
2785
|
-
ensureDir(researchDir);
|
|
2786
|
-
const fileName = `keyword-research-${platform}-${resolvedCountry}.json`;
|
|
2787
|
-
const outputPath = path11.join(researchDir, fileName);
|
|
2788
|
-
const payload = {
|
|
2789
|
-
meta: {
|
|
2790
|
-
slug,
|
|
2791
|
-
locale,
|
|
2792
|
-
platform,
|
|
2793
|
-
country: resolvedCountry,
|
|
2794
|
-
seedKeywords: seeds,
|
|
2795
|
-
competitorApps: competitors,
|
|
2796
|
-
source: "mcp-appstore",
|
|
2797
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2798
|
-
},
|
|
2799
|
-
plan: {
|
|
2800
|
-
steps: [
|
|
2801
|
-
"Start mcp-appstore server (node server.js in external-tools/mcp-appstore).",
|
|
2802
|
-
"Confirm app IDs/locales: get_app_details(appId from config/registered-apps) to lock country/lang and 3\u20135 competitors.",
|
|
2803
|
-
"Discover competitors: search_app(term=seed keyword, num=10\u201320), get_similar_apps(appId=top competitor, num=10).",
|
|
2804
|
-
"Collect candidates (all of these, num=20\u201330): suggest_keywords_by_seeds/by_category/by_similarity/by_competition/by_search; suggest_keywords_by_apps(apps=[top competitors]).",
|
|
2805
|
-
"Score shortlist: get_keyword_scores for 15\u201330 candidates per platform/country.",
|
|
2806
|
-
"Context check: analyze_reviews (num=100\u2013200) and fetch_reviews (num=50\u2013100) on top apps for language/tone cues.",
|
|
2807
|
-
"If keywordSuggestions/similar/reviews are sparse, rerun calls (add more competitors/seeds) until you have 10\u201315 strong keywords.",
|
|
2808
|
-
"For any recommended keyword without scores, rerun get_keyword_scores to fill traffic/difficulty.",
|
|
2809
|
-
"Keep rationale/nextActions in English by default unless you intentionally localize them."
|
|
2810
|
-
],
|
|
2811
|
-
note: "Generated automatically by keyword-research"
|
|
2812
|
-
},
|
|
2813
|
-
data: { raw, summary }
|
|
2814
|
-
};
|
|
2815
|
-
fs11.writeFileSync(outputPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
|
|
2816
|
-
const lines = [];
|
|
2817
|
-
lines.push(`# keyword-research`);
|
|
2818
|
-
lines.push(`Saved to ${outputPath}`);
|
|
2819
2933
|
lines.push(
|
|
2820
|
-
`
|
|
2934
|
+
`8) suggest_keywords_by_similarity + by_competition + by_search (num=30 each)`
|
|
2821
2935
|
);
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2936
|
+
lines.push("");
|
|
2937
|
+
lines.push("### Phase 3: Scoring & Filtering");
|
|
2938
|
+
lines.push(
|
|
2939
|
+
`9) get_keyword_scores for ALL candidates (50-100 keywords) \u2192 get traffic & difficulty`
|
|
2940
|
+
);
|
|
2941
|
+
lines.push(
|
|
2942
|
+
`10) Filter: traffic \u226510, difficulty \u226470, relevant to your app's core value`
|
|
2943
|
+
);
|
|
2944
|
+
lines.push("");
|
|
2945
|
+
lines.push("### Phase 4: User Language Intelligence");
|
|
2946
|
+
lines.push(
|
|
2947
|
+
`11) analyze_reviews(appId=top 3 competitors, num=200) \u2192 sentiment & themes`
|
|
2948
|
+
);
|
|
2949
|
+
lines.push(
|
|
2950
|
+
`12) fetch_reviews(appId=top 3 competitors, num=100) \u2192 extract exact phrases users say`
|
|
2951
|
+
);
|
|
2952
|
+
lines.push(
|
|
2953
|
+
`13) Cross-reference keywords with review language \u2192 validate natural phrasing`
|
|
2954
|
+
);
|
|
2955
|
+
lines.push("");
|
|
2956
|
+
lines.push("### Phase 5: Final Selection");
|
|
2957
|
+
lines.push(
|
|
2958
|
+
`14) Categorize into tiers: Tier1 (3-5 high-traffic core), Tier2 (5-7 feature-match), Tier3 (5-8 longtail)`
|
|
2959
|
+
);
|
|
2960
|
+
lines.push(
|
|
2961
|
+
`15) Validate each keyword has: score, intent, locale fit, inclusion rationale`
|
|
2962
|
+
);
|
|
2963
|
+
lines.push(
|
|
2964
|
+
`16) Save to: ${outputPath}`
|
|
2965
|
+
);
|
|
2966
|
+
lines.push("");
|
|
2967
|
+
lines.push("### Quality Checklist");
|
|
2968
|
+
lines.push("- [ ] 15-20 keywords with complete score data");
|
|
2969
|
+
lines.push("- [ ] All 3 tiers represented (not just longtail)");
|
|
2970
|
+
lines.push("- [ ] Keywords validated against actual review language");
|
|
2971
|
+
lines.push("- [ ] No semantic duplicates");
|
|
2972
|
+
lines.push("- [ ] Locale-appropriate (not direct translations)");
|
|
2973
|
+
if (fileAction) {
|
|
2974
|
+
lines.push(`File: ${fileAction} at ${outputPath}`);
|
|
2975
|
+
if (writeTemplate && !researchData && !researchDataPath) {
|
|
2976
|
+
lines.push(
|
|
2977
|
+
"\u26A0\uFE0F Template is a placeholder\u2014replace with FULL mcp-appstore research results for this locale (no template-only coverage)."
|
|
2978
|
+
);
|
|
2979
|
+
}
|
|
2980
|
+
} else {
|
|
2825
2981
|
lines.push(
|
|
2826
|
-
`
|
|
2982
|
+
`Tip: set writeTemplate=true to create the JSON skeleton at ${outputPath} (still run full research per locale)`
|
|
2827
2983
|
);
|
|
2828
2984
|
}
|
|
2985
|
+
lines.push("");
|
|
2986
|
+
lines.push("Suggested JSON shape:");
|
|
2987
|
+
lines.push("```json");
|
|
2988
|
+
lines.push(templatePreview);
|
|
2989
|
+
lines.push("```");
|
|
2829
2990
|
return {
|
|
2830
|
-
content: [
|
|
2991
|
+
content: [
|
|
2992
|
+
{
|
|
2993
|
+
type: "text",
|
|
2994
|
+
text: lines.join("\n")
|
|
2995
|
+
}
|
|
2996
|
+
]
|
|
2831
2997
|
};
|
|
2832
2998
|
}
|
|
2833
2999
|
|
|
@@ -2880,6 +3046,14 @@ var tools = [
|
|
|
2880
3046
|
zodSchema: keywordResearchInputSchema,
|
|
2881
3047
|
handler: handleKeywordResearch,
|
|
2882
3048
|
category: "ASO Research"
|
|
3049
|
+
},
|
|
3050
|
+
{
|
|
3051
|
+
name: searchAppTool.name,
|
|
3052
|
+
description: searchAppTool.description,
|
|
3053
|
+
inputSchema: searchAppTool.inputSchema,
|
|
3054
|
+
zodSchema: searchAppInputSchema,
|
|
3055
|
+
handler: handleSearchApp,
|
|
3056
|
+
category: "App Management"
|
|
2883
3057
|
}
|
|
2884
3058
|
];
|
|
2885
3059
|
function getToolDefinitions() {
|
|
@@ -2889,7 +3063,8 @@ function getToolDefinitions() {
|
|
|
2889
3063
|
improvePublicTool,
|
|
2890
3064
|
initProjectTool,
|
|
2891
3065
|
createBlogHtmlTool,
|
|
2892
|
-
keywordResearchTool
|
|
3066
|
+
keywordResearchTool,
|
|
3067
|
+
searchAppTool
|
|
2893
3068
|
];
|
|
2894
3069
|
}
|
|
2895
3070
|
function getToolHandler(name) {
|