pabal-web-mcp 1.3.5 → 1.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/mcp-server.js +149 -389
- package/package.json +1 -1
package/dist/bin/mcp-server.js
CHANGED
|
@@ -2380,6 +2380,8 @@ import fs11 from "fs";
|
|
|
2380
2380
|
import path11 from "path";
|
|
2381
2381
|
import { z as z6 } from "zod";
|
|
2382
2382
|
import { zodToJsonSchema as zodToJsonSchema6 } from "zod-to-json-schema";
|
|
2383
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2384
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
2383
2385
|
|
|
2384
2386
|
// src/utils/registered-apps.util.ts
|
|
2385
2387
|
import fs10 from "fs";
|
|
@@ -2413,45 +2415,28 @@ function findRegisteredApp(slug, filePath) {
|
|
|
2413
2415
|
const app = apps.find((a) => a.slug === slug);
|
|
2414
2416
|
return { app, path: usedPath };
|
|
2415
2417
|
}
|
|
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
|
-
}
|
|
2430
2418
|
|
|
2431
2419
|
// src/tools/keyword-research.ts
|
|
2432
2420
|
var TOOL_NAME = "keyword-research";
|
|
2433
2421
|
var keywordResearchInputSchema = z6.object({
|
|
2434
2422
|
slug: z6.string().trim().describe("Product slug"),
|
|
2435
|
-
locale: z6.string().trim().describe(
|
|
2423
|
+
locale: z6.string().trim().describe(
|
|
2424
|
+
"Locale code (e.g., en-US, ko-KR). Stored under .aso/keywordResearch/products/[slug]/locales/."
|
|
2425
|
+
),
|
|
2436
2426
|
platform: z6.enum(["ios", "android"]).default("ios").describe("Store to target ('ios' or 'android'). Run separately per platform."),
|
|
2437
2427
|
country: z6.string().length(2).optional().describe(
|
|
2438
2428
|
"Two-letter store country code. If omitted, derived from locale region (e.g., ko-KR -> kr), else 'us'."
|
|
2439
2429
|
),
|
|
2440
|
-
seedKeywords: z6.array(z6.string().trim()).default([])
|
|
2430
|
+
seedKeywords: z6.array(z6.string().trim()).default([]),
|
|
2441
2431
|
competitorApps: z6.array(
|
|
2442
2432
|
z6.object({
|
|
2443
2433
|
appId: z6.string().trim().describe("App ID (package name or iOS ID/bundle)"),
|
|
2444
2434
|
platform: z6.enum(["ios", "android"])
|
|
2445
2435
|
})
|
|
2446
|
-
).default([])
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
"Optional JSON string with research results (e.g., from mcp-appstore tools). If provided, saves it to the output path."
|
|
2451
|
-
),
|
|
2452
|
-
researchDataPath: z6.string().trim().optional().describe(
|
|
2453
|
-
"Optional path to a JSON file containing research results. If set, file content is saved to the output path (preferred to avoid escape errors)."
|
|
2454
|
-
)
|
|
2436
|
+
).default([]),
|
|
2437
|
+
serverCwd: z6.string().trim().optional().describe("Path to mcp-appstore cwd (default: external-tools/mcp-appstore)"),
|
|
2438
|
+
serverCommand: z6.array(z6.string()).optional().describe("Command + args to start mcp-appstore (default: ['node','server.js'])"),
|
|
2439
|
+
numSuggestions: z6.number().int().positive().max(50).default(20).describe("Number of keyword suggestions per strategy (default: 20)")
|
|
2455
2440
|
});
|
|
2456
2441
|
var jsonSchema6 = zodToJsonSchema6(keywordResearchInputSchema, {
|
|
2457
2442
|
name: "KeywordResearchInput",
|
|
@@ -2460,328 +2445,10 @@ var jsonSchema6 = zodToJsonSchema6(keywordResearchInputSchema, {
|
|
|
2460
2445
|
var inputSchema6 = jsonSchema6.definitions?.KeywordResearchInput || jsonSchema6;
|
|
2461
2446
|
var keywordResearchTool = {
|
|
2462
2447
|
name: TOOL_NAME,
|
|
2463
|
-
description:
|
|
2464
|
-
|
|
2465
|
-
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.`,
|
|
2448
|
+
description: "Runs mcp-appstore calls to collect 10\u201315 keywords per locale and saves to .aso/keywordResearch/products/[slug]/locales/[locale]/keyword-research-[platform]-[country].json.",
|
|
2466
2449
|
inputSchema: inputSchema6
|
|
2467
2450
|
};
|
|
2468
|
-
function buildTemplate({
|
|
2469
|
-
slug,
|
|
2470
|
-
locale,
|
|
2471
|
-
platform,
|
|
2472
|
-
country,
|
|
2473
|
-
seedKeywords,
|
|
2474
|
-
competitorApps
|
|
2475
|
-
}) {
|
|
2476
|
-
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
2477
|
-
return {
|
|
2478
|
-
meta: {
|
|
2479
|
-
slug,
|
|
2480
|
-
locale,
|
|
2481
|
-
platform,
|
|
2482
|
-
country,
|
|
2483
|
-
seedKeywords,
|
|
2484
|
-
competitorApps,
|
|
2485
|
-
source: "mcp-appstore",
|
|
2486
|
-
updatedAt: timestamp
|
|
2487
|
-
},
|
|
2488
|
-
plan: {
|
|
2489
|
-
steps: [
|
|
2490
|
-
"Start mcp-appstore server (node server.js in external-tools/mcp-appstore).",
|
|
2491
|
-
"Confirm app IDs/locales: get_app_details(appId from config/registered-apps) to lock country/lang and 3\u20135 competitors.",
|
|
2492
|
-
"Discover competitors: search_app(term=seed keyword, num=10\u201320), get_similar_apps(appId=top competitor, num=10).",
|
|
2493
|
-
"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]).",
|
|
2494
|
-
"Score shortlist: get_keyword_scores for 15\u201330 candidates per platform/country.",
|
|
2495
|
-
"Context check: analyze_reviews (num=100\u2013200) and fetch_reviews (num=50\u2013100) on top apps for language/tone cues.",
|
|
2496
|
-
"If keywordSuggestions/similar/reviews are sparse, rerun calls (add more competitors/seeds) until you have 10\u201315 strong keywords.",
|
|
2497
|
-
"For any recommended keyword without scores, rerun get_keyword_scores to fill traffic/difficulty.",
|
|
2498
|
-
"Keep rationale/nextActions in English by default unless you intentionally localize them."
|
|
2499
|
-
],
|
|
2500
|
-
note: "Run per platform/country. Save raw tool outputs plus curated top keywords (target 10\u201315 per locale: 2\u20133 high-traffic core, 4\u20136 mid-competition, 4\u20136 longtail). Minimum checklist before saving: search_app results present, keywordSuggestions.* NOT empty, keywordScores >= 10 items, recommendedKeywords 10\u201315 items."
|
|
2501
|
-
},
|
|
2502
|
-
data: {
|
|
2503
|
-
raw: {
|
|
2504
|
-
searchApp: [],
|
|
2505
|
-
getAppDetails: [],
|
|
2506
|
-
similarApps: [],
|
|
2507
|
-
keywordSuggestions: {
|
|
2508
|
-
bySeeds: [],
|
|
2509
|
-
byCategory: [],
|
|
2510
|
-
bySimilarity: [],
|
|
2511
|
-
byCompetition: [],
|
|
2512
|
-
bySearchHints: [],
|
|
2513
|
-
byApps: []
|
|
2514
|
-
},
|
|
2515
|
-
keywordScores: [],
|
|
2516
|
-
reviewsAnalysis: [],
|
|
2517
|
-
reviewsRaw: []
|
|
2518
|
-
},
|
|
2519
|
-
summary: {
|
|
2520
|
-
recommendedKeywords: [],
|
|
2521
|
-
rationale: "",
|
|
2522
|
-
nextActions: "Feed 10\u201315 mixed keywords (core/mid/longtail) into improve-public Stage 1."
|
|
2523
|
-
}
|
|
2524
|
-
}
|
|
2525
|
-
};
|
|
2526
|
-
}
|
|
2527
|
-
function saveJsonFile({
|
|
2528
|
-
researchDir,
|
|
2529
|
-
fileName,
|
|
2530
|
-
payload
|
|
2531
|
-
}) {
|
|
2532
|
-
fs11.mkdirSync(researchDir, { recursive: true });
|
|
2533
|
-
const outputPath = path11.join(researchDir, fileName);
|
|
2534
|
-
fs11.writeFileSync(outputPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
|
|
2535
|
-
return outputPath;
|
|
2536
|
-
}
|
|
2537
2451
|
function normalizeKeywords(raw) {
|
|
2538
|
-
if (!raw) return [];
|
|
2539
|
-
if (Array.isArray(raw)) {
|
|
2540
|
-
return raw.map((k) => k.trim()).filter((k) => k.length > 0);
|
|
2541
|
-
}
|
|
2542
|
-
return raw.split(",").map((k) => k.trim()).filter((k) => k.length > 0);
|
|
2543
|
-
}
|
|
2544
|
-
async function handleKeywordResearch(input) {
|
|
2545
|
-
const {
|
|
2546
|
-
slug,
|
|
2547
|
-
locale,
|
|
2548
|
-
platform = "ios",
|
|
2549
|
-
country,
|
|
2550
|
-
seedKeywords = [],
|
|
2551
|
-
competitorApps = [],
|
|
2552
|
-
filename,
|
|
2553
|
-
writeTemplate = false,
|
|
2554
|
-
researchData,
|
|
2555
|
-
researchDataPath
|
|
2556
|
-
} = input;
|
|
2557
|
-
const { config, locales } = loadProductLocales(slug);
|
|
2558
|
-
const primaryLocale = resolvePrimaryLocale(config, locales);
|
|
2559
|
-
const primaryLocaleData = locales[primaryLocale];
|
|
2560
|
-
const { app: registeredApp, path: registeredPath } = findRegisteredApp(slug);
|
|
2561
|
-
const { supportedLocales, path: supportedPath } = getSupportedLocalesForSlug(slug, platform);
|
|
2562
|
-
const autoSeeds = [];
|
|
2563
|
-
const autoCompetitors = [];
|
|
2564
|
-
if (primaryLocaleData?.aso?.title) {
|
|
2565
|
-
autoSeeds.push(primaryLocaleData.aso.title);
|
|
2566
|
-
}
|
|
2567
|
-
const parsedKeywords = normalizeKeywords(primaryLocaleData?.aso?.keywords);
|
|
2568
|
-
autoSeeds.push(...parsedKeywords.slice(0, 5));
|
|
2569
|
-
if (config?.name) autoSeeds.push(config.name);
|
|
2570
|
-
if (config?.tagline) autoSeeds.push(config.tagline);
|
|
2571
|
-
if (!config?.name && registeredApp?.name) autoSeeds.push(registeredApp.name);
|
|
2572
|
-
if (!primaryLocaleData?.aso?.title) {
|
|
2573
|
-
if (platform === "ios" && registeredApp?.appStore?.name) {
|
|
2574
|
-
autoSeeds.push(registeredApp.appStore.name);
|
|
2575
|
-
}
|
|
2576
|
-
if (platform === "android" && registeredApp?.googlePlay?.name) {
|
|
2577
|
-
autoSeeds.push(registeredApp.googlePlay.name);
|
|
2578
|
-
}
|
|
2579
|
-
}
|
|
2580
|
-
if (platform === "ios") {
|
|
2581
|
-
if (config?.appStoreAppId) {
|
|
2582
|
-
autoCompetitors.push({ appId: String(config.appStoreAppId), platform });
|
|
2583
|
-
} else if (config?.bundleId) {
|
|
2584
|
-
autoCompetitors.push({ appId: config.bundleId, platform });
|
|
2585
|
-
} else if (registeredApp?.appStore?.appId) {
|
|
2586
|
-
autoCompetitors.push({
|
|
2587
|
-
appId: String(registeredApp.appStore.appId),
|
|
2588
|
-
platform
|
|
2589
|
-
});
|
|
2590
|
-
} else if (registeredApp?.appStore?.bundleId) {
|
|
2591
|
-
autoCompetitors.push({
|
|
2592
|
-
appId: registeredApp.appStore.bundleId,
|
|
2593
|
-
platform
|
|
2594
|
-
});
|
|
2595
|
-
}
|
|
2596
|
-
} else if (platform === "android" && config?.packageName) {
|
|
2597
|
-
autoCompetitors.push({ appId: config.packageName, platform });
|
|
2598
|
-
} else if (platform === "android" && registeredApp?.googlePlay?.packageName) {
|
|
2599
|
-
autoCompetitors.push({
|
|
2600
|
-
appId: registeredApp.googlePlay.packageName,
|
|
2601
|
-
platform
|
|
2602
|
-
});
|
|
2603
|
-
}
|
|
2604
|
-
const resolvedSeeds = seedKeywords.length > 0 ? seedKeywords : Array.from(new Set(autoSeeds));
|
|
2605
|
-
const resolvedCompetitors = competitorApps.length > 0 ? competitorApps : autoCompetitors;
|
|
2606
|
-
const resolvedCountry = country || (locale?.includes("-") ? locale.split("-")[1].toLowerCase() : "us");
|
|
2607
|
-
const researchDir = path11.join(
|
|
2608
|
-
getKeywordResearchDir(),
|
|
2609
|
-
"products",
|
|
2610
|
-
slug,
|
|
2611
|
-
"locales",
|
|
2612
|
-
locale
|
|
2613
|
-
);
|
|
2614
|
-
const defaultFileName = `keyword-research-${platform}-${resolvedCountry}.json`;
|
|
2615
|
-
const fileName = filename || defaultFileName;
|
|
2616
|
-
let outputPath = path11.join(researchDir, fileName);
|
|
2617
|
-
let fileAction;
|
|
2618
|
-
const parseJsonWithContext = (text) => {
|
|
2619
|
-
try {
|
|
2620
|
-
return JSON.parse(text);
|
|
2621
|
-
} catch (err) {
|
|
2622
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
2623
|
-
const match = /position (\d+)/i.exec(message) || /column (\d+)/i.exec(message) || /char (\d+)/i.exec(message);
|
|
2624
|
-
if (match) {
|
|
2625
|
-
const pos = Number(match[1]);
|
|
2626
|
-
const start = Math.max(0, pos - 40);
|
|
2627
|
-
const end = Math.min(text.length, pos + 40);
|
|
2628
|
-
const context = text.slice(start, end);
|
|
2629
|
-
throw new Error(
|
|
2630
|
-
`Failed to parse researchData JSON: ${message}
|
|
2631
|
-
Context around ${pos}: ${context}`
|
|
2632
|
-
);
|
|
2633
|
-
}
|
|
2634
|
-
throw new Error(`Failed to parse researchData JSON: ${message}`);
|
|
2635
|
-
}
|
|
2636
|
-
};
|
|
2637
|
-
const loadResearchDataFromPath = (p) => {
|
|
2638
|
-
if (!fs11.existsSync(p)) {
|
|
2639
|
-
throw new Error(`researchDataPath not found: ${p}`);
|
|
2640
|
-
}
|
|
2641
|
-
const raw = fs11.readFileSync(p, "utf-8");
|
|
2642
|
-
return parseJsonWithContext(raw);
|
|
2643
|
-
};
|
|
2644
|
-
if (writeTemplate || researchData) {
|
|
2645
|
-
const payload = researchData ? parseJsonWithContext(researchData) : researchDataPath ? loadResearchDataFromPath(researchDataPath) : buildTemplate({
|
|
2646
|
-
slug,
|
|
2647
|
-
locale,
|
|
2648
|
-
platform,
|
|
2649
|
-
country: resolvedCountry,
|
|
2650
|
-
seedKeywords: resolvedSeeds,
|
|
2651
|
-
competitorApps: resolvedCompetitors
|
|
2652
|
-
});
|
|
2653
|
-
outputPath = saveJsonFile({ researchDir, fileName, payload });
|
|
2654
|
-
fileAction = researchData ? "Saved provided researchData" : "Wrote template";
|
|
2655
|
-
}
|
|
2656
|
-
const templatePreview = JSON.stringify(
|
|
2657
|
-
buildTemplate({
|
|
2658
|
-
slug,
|
|
2659
|
-
locale,
|
|
2660
|
-
platform,
|
|
2661
|
-
country: resolvedCountry,
|
|
2662
|
-
seedKeywords: resolvedSeeds,
|
|
2663
|
-
competitorApps: resolvedCompetitors
|
|
2664
|
-
}),
|
|
2665
|
-
null,
|
|
2666
|
-
2
|
|
2667
|
-
);
|
|
2668
|
-
const lines = [];
|
|
2669
|
-
lines.push(`# Keyword research plan (${slug})`);
|
|
2670
|
-
lines.push(`Locale: ${locale} | Platform: ${platform} | Country: ${resolvedCountry}`);
|
|
2671
|
-
lines.push(`Primary locale detected: ${primaryLocale}`);
|
|
2672
|
-
if (supportedLocales.length > 0) {
|
|
2673
|
-
lines.push(
|
|
2674
|
-
`Registered supported locales (${platform}): ${supportedLocales.join(
|
|
2675
|
-
", "
|
|
2676
|
-
)} (source: ${supportedPath})`
|
|
2677
|
-
);
|
|
2678
|
-
if (!supportedLocales.includes(locale)) {
|
|
2679
|
-
lines.push(
|
|
2680
|
-
`WARNING: locale ${locale} not in registered supported locales. Confirm this locale or update registered-apps.json.`
|
|
2681
|
-
);
|
|
2682
|
-
}
|
|
2683
|
-
} else {
|
|
2684
|
-
lines.push(
|
|
2685
|
-
`Registered supported locales not found for ${platform} (checked: ${supportedPath}).`
|
|
2686
|
-
);
|
|
2687
|
-
}
|
|
2688
|
-
lines.push(
|
|
2689
|
-
`Seeds: ${resolvedSeeds.length > 0 ? resolvedSeeds.join(", ") : "(none set; add seedKeywords or ensure ASO keywords/title exist)"}`
|
|
2690
|
-
);
|
|
2691
|
-
lines.push(
|
|
2692
|
-
`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)"}`
|
|
2693
|
-
);
|
|
2694
|
-
lines.push("");
|
|
2695
|
-
lines.push("How to run (uses mcp-appstore):");
|
|
2696
|
-
lines.push(
|
|
2697
|
-
`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.`
|
|
2698
|
-
);
|
|
2699
|
-
lines.push(
|
|
2700
|
-
`2) Confirm IDs/locales: get_app_details(appId from config/registered-apps) to lock locale/country and competitor list.`
|
|
2701
|
-
);
|
|
2702
|
-
lines.push(
|
|
2703
|
-
`3) Discover apps: search_app(term=seed, num=10-20, platform=${platform}, country=${resolvedCountry}); get_similar_apps(appId=top competitor, num=10).`
|
|
2704
|
-
);
|
|
2705
|
-
lines.push(
|
|
2706
|
-
`4) Expand keywords (num=20-30 each): suggest_keywords_by_seeds/by_category/by_similarity/by_competition/by_search + suggest_keywords_by_apps(apps=[top competitors]).`
|
|
2707
|
-
);
|
|
2708
|
-
lines.push(
|
|
2709
|
-
`5) Score shortlist: get_keyword_scores for 15\u201330 candidates (note: scores are heuristic per README).`
|
|
2710
|
-
);
|
|
2711
|
-
lines.push(
|
|
2712
|
-
`6) Context check: analyze_reviews (num=100-200) and fetch_reviews (num=50-100) on top apps to harvest native phrasing; keep snippets for improve-public.`
|
|
2713
|
-
);
|
|
2714
|
-
lines.push(
|
|
2715
|
-
`7) Save all raw responses + your final 10\u201315 keywords (mix of core/high-traffic, mid, longtail) to: ${outputPath} (structure mirrors .aso/pullData/.aso/pushData under products/<slug>/locales/<locale>)`
|
|
2716
|
-
);
|
|
2717
|
-
lines.push(
|
|
2718
|
-
`8) If keywordSuggestions/similarApps/reviews are still empty or <10 solid candidates, add more competitors/seeds and rerun the calls above until you reach 10\u201315 strong keywords.`
|
|
2719
|
-
);
|
|
2720
|
-
lines.push(
|
|
2721
|
-
`9) If any recommended keywords lack scores, rerun get_keyword_scores for those items. Keep rationale/nextActions in English by default unless you explicitly want them localized.`
|
|
2722
|
-
);
|
|
2723
|
-
lines.push(
|
|
2724
|
-
`10) Do not finalize until: search_app has results, keywordSuggestions.* have entries, keywordScores >=10 items, recommendedKeywords 10\u201315 items. If missing, rerun the relevant calls.`
|
|
2725
|
-
);
|
|
2726
|
-
if (fileAction) {
|
|
2727
|
-
lines.push(`File: ${fileAction} at ${outputPath}`);
|
|
2728
|
-
} else {
|
|
2729
|
-
lines.push(
|
|
2730
|
-
`Tip: set writeTemplate=true to create the JSON skeleton at ${outputPath}`
|
|
2731
|
-
);
|
|
2732
|
-
}
|
|
2733
|
-
lines.push("");
|
|
2734
|
-
lines.push("Suggested JSON shape:");
|
|
2735
|
-
lines.push("```json");
|
|
2736
|
-
lines.push(templatePreview);
|
|
2737
|
-
lines.push("```");
|
|
2738
|
-
return {
|
|
2739
|
-
content: [
|
|
2740
|
-
{
|
|
2741
|
-
type: "text",
|
|
2742
|
-
text: lines.join("\n")
|
|
2743
|
-
}
|
|
2744
|
-
]
|
|
2745
|
-
};
|
|
2746
|
-
}
|
|
2747
|
-
|
|
2748
|
-
// src/tools/keyword-research-runner.ts
|
|
2749
|
-
import fs12 from "fs";
|
|
2750
|
-
import path12 from "path";
|
|
2751
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2752
|
-
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
2753
|
-
import { z as z7 } from "zod";
|
|
2754
|
-
import { zodToJsonSchema as zodToJsonSchema7 } from "zod-to-json-schema";
|
|
2755
|
-
var TOOL_NAME2 = "keyword-research-runner";
|
|
2756
|
-
var keywordResearchRunnerInputSchema = z7.object({
|
|
2757
|
-
slug: z7.string().trim().describe("Product slug"),
|
|
2758
|
-
locale: z7.string().trim().describe("Locale code (e.g., en-US, ko-KR)"),
|
|
2759
|
-
platform: z7.enum(["ios", "android"]).default("ios"),
|
|
2760
|
-
country: z7.string().length(2).optional().describe("Store country (default derived from locale region or 'us')"),
|
|
2761
|
-
seedKeywords: z7.array(z7.string().trim()).default([]),
|
|
2762
|
-
competitorApps: z7.array(
|
|
2763
|
-
z7.object({
|
|
2764
|
-
appId: z7.string().trim(),
|
|
2765
|
-
platform: z7.enum(["ios", "android"])
|
|
2766
|
-
})
|
|
2767
|
-
).default([]),
|
|
2768
|
-
serverCwd: z7.string().trim().optional().describe("Path to mcp-appstore cwd (default: external-tools/mcp-appstore)"),
|
|
2769
|
-
serverCommand: z7.array(z7.string()).optional().describe(
|
|
2770
|
-
"Command + args to start mcp-appstore (default: ['node','server.js'])"
|
|
2771
|
-
),
|
|
2772
|
-
numSuggestions: z7.number().int().positive().max(50).default(20).describe("Number of keyword suggestions per strategy (default: 20)")
|
|
2773
|
-
});
|
|
2774
|
-
var jsonSchema7 = zodToJsonSchema7(keywordResearchRunnerInputSchema, {
|
|
2775
|
-
name: "KeywordResearchRunnerInput",
|
|
2776
|
-
$refStrategy: "none"
|
|
2777
|
-
});
|
|
2778
|
-
var inputSchema7 = jsonSchema7.definitions?.KeywordResearchRunnerInput || jsonSchema7;
|
|
2779
|
-
var keywordResearchRunnerTool = {
|
|
2780
|
-
name: TOOL_NAME2,
|
|
2781
|
-
description: "Automatically call mcp-appstore tools to collect 10\u201315 keywords per locale and save to .aso/keywordResearch/...",
|
|
2782
|
-
inputSchema: inputSchema7
|
|
2783
|
-
};
|
|
2784
|
-
function normalizeKeywords2(raw) {
|
|
2785
2452
|
if (!raw) return [];
|
|
2786
2453
|
if (Array.isArray(raw)) {
|
|
2787
2454
|
return raw.map((k) => k.trim()).filter(Boolean);
|
|
@@ -2799,9 +2466,9 @@ async function callToolJSON(client, name, args) {
|
|
|
2799
2466
|
}
|
|
2800
2467
|
}
|
|
2801
2468
|
function ensureDir(dir) {
|
|
2802
|
-
|
|
2469
|
+
fs11.mkdirSync(dir, { recursive: true });
|
|
2803
2470
|
}
|
|
2804
|
-
async function
|
|
2471
|
+
async function handleKeywordResearch(input) {
|
|
2805
2472
|
const {
|
|
2806
2473
|
slug,
|
|
2807
2474
|
locale,
|
|
@@ -2815,12 +2482,12 @@ async function handleKeywordResearchRunner(input) {
|
|
|
2815
2482
|
} = input;
|
|
2816
2483
|
const { config, locales } = loadProductLocales(slug);
|
|
2817
2484
|
const primaryLocale = resolvePrimaryLocale(config, locales);
|
|
2485
|
+
const primaryLocaleData = locales[primaryLocale];
|
|
2818
2486
|
const { app: registeredApp } = findRegisteredApp(slug);
|
|
2819
2487
|
const resolvedCountry = country || (locale.includes("-") ? locale.split("-")[1].toLowerCase() : "us");
|
|
2820
2488
|
const autoSeeds = [];
|
|
2821
|
-
const primaryLocaleData = locales[primaryLocale];
|
|
2822
2489
|
if (primaryLocaleData?.aso?.title) autoSeeds.push(primaryLocaleData.aso.title);
|
|
2823
|
-
const parsedKeywords =
|
|
2490
|
+
const parsedKeywords = normalizeKeywords(primaryLocaleData?.aso?.keywords);
|
|
2824
2491
|
autoSeeds.push(...parsedKeywords.slice(0, 5));
|
|
2825
2492
|
if (config?.name) autoSeeds.push(config.name);
|
|
2826
2493
|
if (config?.tagline) autoSeeds.push(config.tagline);
|
|
@@ -2837,9 +2504,15 @@ async function handleKeywordResearchRunner(input) {
|
|
|
2837
2504
|
} else if (config?.bundleId) {
|
|
2838
2505
|
autoCompetitors.push({ appId: config.bundleId, platform });
|
|
2839
2506
|
} else if (registeredApp?.appStore?.appId) {
|
|
2840
|
-
autoCompetitors.push({
|
|
2507
|
+
autoCompetitors.push({
|
|
2508
|
+
appId: String(registeredApp.appStore.appId),
|
|
2509
|
+
platform
|
|
2510
|
+
});
|
|
2841
2511
|
} else if (registeredApp?.appStore?.bundleId) {
|
|
2842
|
-
autoCompetitors.push({
|
|
2512
|
+
autoCompetitors.push({
|
|
2513
|
+
appId: registeredApp.appStore.bundleId,
|
|
2514
|
+
platform
|
|
2515
|
+
});
|
|
2843
2516
|
}
|
|
2844
2517
|
} else {
|
|
2845
2518
|
if (config?.packageName) {
|
|
@@ -2859,12 +2532,24 @@ async function handleKeywordResearchRunner(input) {
|
|
|
2859
2532
|
])
|
|
2860
2533
|
).values()
|
|
2861
2534
|
).slice(0, 5);
|
|
2862
|
-
const cwd = serverCwd ||
|
|
2863
|
-
const cmd = serverCommand?.[0] || "
|
|
2864
|
-
const args = serverCommand?.length ? serverCommand.slice(1) : ["
|
|
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
|
+
);
|
|
2549
|
+
}
|
|
2865
2550
|
const transport = new StdioClientTransport({ command: cmd, args, cwd });
|
|
2866
2551
|
const client = new Client(
|
|
2867
|
-
{ name: "keyword-research
|
|
2552
|
+
{ name: "keyword-research", version: "1.0.0" },
|
|
2868
2553
|
{ capabilities: { tools: {} } }
|
|
2869
2554
|
);
|
|
2870
2555
|
await client.connect(transport);
|
|
@@ -2872,6 +2557,7 @@ async function handleKeywordResearchRunner(input) {
|
|
|
2872
2557
|
searchApp: [],
|
|
2873
2558
|
getAppDetails: [],
|
|
2874
2559
|
similarApps: [],
|
|
2560
|
+
searchAppFallbackApps: [],
|
|
2875
2561
|
keywordSuggestions: {
|
|
2876
2562
|
bySeeds: [],
|
|
2877
2563
|
byCategory: [],
|
|
@@ -2884,6 +2570,7 @@ async function handleKeywordResearchRunner(input) {
|
|
|
2884
2570
|
reviewsAnalysis: [],
|
|
2885
2571
|
reviewsRaw: []
|
|
2886
2572
|
};
|
|
2573
|
+
const fallbackAppIds = [];
|
|
2887
2574
|
for (const term of seeds.slice(0, 3)) {
|
|
2888
2575
|
const res = await callToolJSON(client, "search_app", {
|
|
2889
2576
|
term,
|
|
@@ -2891,8 +2578,19 @@ async function handleKeywordResearchRunner(input) {
|
|
|
2891
2578
|
num: 20,
|
|
2892
2579
|
country: resolvedCountry
|
|
2893
2580
|
});
|
|
2894
|
-
raw.searchApp.push({
|
|
2581
|
+
raw.searchApp.push({
|
|
2582
|
+
query: term,
|
|
2583
|
+
platform,
|
|
2584
|
+
country: resolvedCountry,
|
|
2585
|
+
results: res?.results ?? res
|
|
2586
|
+
});
|
|
2587
|
+
const apps = (res?.results ?? res) || [];
|
|
2588
|
+
apps.slice(0, 5).forEach((app) => {
|
|
2589
|
+
const appId = app?.appId || app?.id;
|
|
2590
|
+
if (appId) fallbackAppIds.push(String(appId));
|
|
2591
|
+
});
|
|
2895
2592
|
}
|
|
2593
|
+
raw.searchAppFallbackApps = Array.from(new Set(fallbackAppIds));
|
|
2896
2594
|
for (const comp of competitors.slice(0, 5)) {
|
|
2897
2595
|
const res = await callToolJSON(client, "get_app_details", {
|
|
2898
2596
|
appId: comp.appId,
|
|
@@ -2901,47 +2599,77 @@ async function handleKeywordResearchRunner(input) {
|
|
|
2901
2599
|
});
|
|
2902
2600
|
raw.getAppDetails.push(res);
|
|
2903
2601
|
}
|
|
2904
|
-
|
|
2905
|
-
|
|
2602
|
+
const firstCompId = competitors[0]?.appId || raw.searchAppFallbackApps[0];
|
|
2603
|
+
if (firstCompId) {
|
|
2906
2604
|
const res = await callToolJSON(client, "get_similar_apps", {
|
|
2907
|
-
appId:
|
|
2605
|
+
appId: firstCompId,
|
|
2908
2606
|
platform,
|
|
2909
2607
|
num: 10,
|
|
2910
2608
|
country: resolvedCountry
|
|
2911
2609
|
});
|
|
2912
2610
|
raw.similarApps = res?.similarApps || res || [];
|
|
2913
2611
|
}
|
|
2914
|
-
const firstComp =
|
|
2915
|
-
const appsList = competitors.map((c) => c.appId);
|
|
2612
|
+
const firstComp = firstCompId;
|
|
2613
|
+
const appsList = competitors.length ? competitors.map((c) => c.appId) : raw.searchAppFallbackApps.slice(0, 5);
|
|
2916
2614
|
const suggestCalls = [
|
|
2917
2615
|
[
|
|
2918
2616
|
"suggest_keywords_by_seeds",
|
|
2919
|
-
{
|
|
2617
|
+
{
|
|
2618
|
+
keywords: seeds,
|
|
2619
|
+
platform,
|
|
2620
|
+
num: numSuggestions,
|
|
2621
|
+
country: resolvedCountry
|
|
2622
|
+
},
|
|
2920
2623
|
"bySeeds"
|
|
2921
2624
|
],
|
|
2922
2625
|
[
|
|
2923
2626
|
"suggest_keywords_by_category",
|
|
2924
|
-
firstComp ? {
|
|
2627
|
+
firstComp ? {
|
|
2628
|
+
appId: firstComp,
|
|
2629
|
+
platform,
|
|
2630
|
+
num: numSuggestions,
|
|
2631
|
+
country: resolvedCountry
|
|
2632
|
+
} : null,
|
|
2925
2633
|
"byCategory"
|
|
2926
2634
|
],
|
|
2927
2635
|
[
|
|
2928
2636
|
"suggest_keywords_by_similarity",
|
|
2929
|
-
firstComp ? {
|
|
2637
|
+
firstComp ? {
|
|
2638
|
+
appId: firstComp,
|
|
2639
|
+
platform,
|
|
2640
|
+
num: numSuggestions,
|
|
2641
|
+
country: resolvedCountry
|
|
2642
|
+
} : null,
|
|
2930
2643
|
"bySimilarity"
|
|
2931
2644
|
],
|
|
2932
2645
|
[
|
|
2933
2646
|
"suggest_keywords_by_competition",
|
|
2934
|
-
firstComp ? {
|
|
2647
|
+
firstComp ? {
|
|
2648
|
+
appId: firstComp,
|
|
2649
|
+
platform,
|
|
2650
|
+
num: numSuggestions,
|
|
2651
|
+
country: resolvedCountry
|
|
2652
|
+
} : null,
|
|
2935
2653
|
"byCompetition"
|
|
2936
2654
|
],
|
|
2937
2655
|
[
|
|
2938
2656
|
"suggest_keywords_by_search",
|
|
2939
|
-
{
|
|
2657
|
+
{
|
|
2658
|
+
keywords: seeds.slice(0, 5),
|
|
2659
|
+
platform,
|
|
2660
|
+
num: numSuggestions,
|
|
2661
|
+
country: resolvedCountry
|
|
2662
|
+
},
|
|
2940
2663
|
"bySearchHints"
|
|
2941
2664
|
],
|
|
2942
2665
|
[
|
|
2943
2666
|
"suggest_keywords_by_apps",
|
|
2944
|
-
appsList.length ? {
|
|
2667
|
+
appsList.length ? {
|
|
2668
|
+
apps: appsList,
|
|
2669
|
+
platform,
|
|
2670
|
+
num: numSuggestions,
|
|
2671
|
+
country: resolvedCountry
|
|
2672
|
+
} : null,
|
|
2945
2673
|
"byApps"
|
|
2946
2674
|
]
|
|
2947
2675
|
];
|
|
@@ -2967,6 +2695,23 @@ async function handleKeywordResearchRunner(input) {
|
|
|
2967
2695
|
});
|
|
2968
2696
|
raw.reviewsRaw.push(rawReviews);
|
|
2969
2697
|
}
|
|
2698
|
+
if (competitors.length === 0 && raw.searchAppFallbackApps.length > 0) {
|
|
2699
|
+
const fb = raw.searchAppFallbackApps[0];
|
|
2700
|
+
const analysis = await callToolJSON(client, "analyze_reviews", {
|
|
2701
|
+
appId: fb,
|
|
2702
|
+
platform,
|
|
2703
|
+
country: resolvedCountry,
|
|
2704
|
+
num: 150
|
|
2705
|
+
});
|
|
2706
|
+
raw.reviewsAnalysis.push(analysis);
|
|
2707
|
+
const rawReviews = await callToolJSON(client, "fetch_reviews", {
|
|
2708
|
+
appId: fb,
|
|
2709
|
+
platform,
|
|
2710
|
+
country: resolvedCountry,
|
|
2711
|
+
num: 80
|
|
2712
|
+
});
|
|
2713
|
+
raw.reviewsRaw.push(rawReviews);
|
|
2714
|
+
}
|
|
2970
2715
|
const candidateSet = /* @__PURE__ */ new Set();
|
|
2971
2716
|
const addCandidates = (arr) => {
|
|
2972
2717
|
if (!arr) return;
|
|
@@ -2974,13 +2719,18 @@ async function handleKeywordResearchRunner(input) {
|
|
|
2974
2719
|
arr.forEach((v) => {
|
|
2975
2720
|
if (typeof v === "string") candidateSet.add(v);
|
|
2976
2721
|
if (v?.keyword) candidateSet.add(String(v.keyword));
|
|
2977
|
-
if (v?.suggestions)
|
|
2978
|
-
addCandidates(v.suggestions);
|
|
2722
|
+
if (v?.suggestions) addCandidates(v.suggestions);
|
|
2979
2723
|
});
|
|
2980
2724
|
}
|
|
2981
2725
|
};
|
|
2982
2726
|
seeds.forEach((s) => candidateSet.add(s));
|
|
2983
2727
|
Object.values(raw.keywordSuggestions).forEach((arr) => addCandidates(arr));
|
|
2728
|
+
raw.reviewsAnalysis.forEach((ra) => {
|
|
2729
|
+
const freq = ra?.analysis?.keywordFrequency || ra?.keywordFrequency;
|
|
2730
|
+
if (freq && typeof freq === "object") {
|
|
2731
|
+
addCandidates(Object.keys(freq));
|
|
2732
|
+
}
|
|
2733
|
+
});
|
|
2984
2734
|
const candidates = Array.from(candidateSet).slice(0, 30);
|
|
2985
2735
|
for (const kw of candidates) {
|
|
2986
2736
|
const res = await callToolJSON(client, "get_keyword_scores", {
|
|
@@ -2992,7 +2742,9 @@ async function handleKeywordResearchRunner(input) {
|
|
|
2992
2742
|
}
|
|
2993
2743
|
const scored = raw.keywordScores.map((s) => {
|
|
2994
2744
|
const trafficScore = typeof s?.scores?.traffic?.score === "number" ? s.scores.traffic.score : parseFloat(s?.traffic?.score ?? s?.scores?.traffic?.score ?? "0") || 0;
|
|
2995
|
-
const difficultyScore = typeof s?.scores?.difficulty?.score === "number" ? s.scores.difficulty.score : parseFloat(
|
|
2745
|
+
const difficultyScore = typeof s?.scores?.difficulty?.score === "number" ? s.scores.difficulty.score : parseFloat(
|
|
2746
|
+
s?.difficulty?.score ?? s?.scores?.difficulty?.score ?? "0"
|
|
2747
|
+
) || 0;
|
|
2996
2748
|
return {
|
|
2997
2749
|
keyword: s.keyword || s?.scores?.keyword,
|
|
2998
2750
|
trafficScore,
|
|
@@ -3001,16 +2753,29 @@ async function handleKeywordResearchRunner(input) {
|
|
|
3001
2753
|
};
|
|
3002
2754
|
}).filter((s) => s.keyword);
|
|
3003
2755
|
const recommended = scored.sort((a, b) => b.trafficScore - a.trafficScore).slice(0, 15);
|
|
2756
|
+
const recommendedKeywords = recommended.map((r) => r.keyword).filter(Boolean);
|
|
2757
|
+
const top1 = recommended[0];
|
|
2758
|
+
const top3 = recommendedKeywords.slice(0, 3);
|
|
2759
|
+
const rationaleText = top1 && top1.keyword ? `Highest traffic: ${top1.keyword} (traffic ${top1.trafficScore.toFixed(
|
|
2760
|
+
2
|
|
2761
|
+
)}, difficulty ${top1.difficultyScore.toFixed(
|
|
2762
|
+
2
|
|
2763
|
+
)}). Blend core/high-traffic and mid/longtail terms for coverage.` : "Sorted by traffic score (desc). Blend of core/high-traffic and mid/longtail technical terms.";
|
|
2764
|
+
const nextActionsText = recommendedKeywords.length ? `Use top keywords in improve-public Stage 1. Emphasize: ${top3.join(
|
|
2765
|
+
", "
|
|
2766
|
+
)}. Keep app name in English; target 10\u201315 keywords in keyword fields.` : "Run again to produce at least 10\u201315 keywords, then feed into improve-public Stage 1.";
|
|
3004
2767
|
const summary = {
|
|
3005
|
-
recommendedKeywords
|
|
2768
|
+
recommendedKeywords,
|
|
2769
|
+
recommendedDetails: recommended.map((r) => ({
|
|
3006
2770
|
keyword: r.keyword,
|
|
3007
2771
|
trafficScore: r.trafficScore,
|
|
3008
2772
|
difficultyScore: r.difficultyScore
|
|
3009
2773
|
})),
|
|
3010
|
-
rationale:
|
|
3011
|
-
nextActions:
|
|
2774
|
+
rationale: rationaleText,
|
|
2775
|
+
nextActions: nextActionsText
|
|
3012
2776
|
};
|
|
3013
|
-
|
|
2777
|
+
await client.close();
|
|
2778
|
+
const researchDir = path11.join(
|
|
3014
2779
|
getKeywordResearchDir(),
|
|
3015
2780
|
"products",
|
|
3016
2781
|
slug,
|
|
@@ -3019,7 +2784,7 @@ async function handleKeywordResearchRunner(input) {
|
|
|
3019
2784
|
);
|
|
3020
2785
|
ensureDir(researchDir);
|
|
3021
2786
|
const fileName = `keyword-research-${platform}-${resolvedCountry}.json`;
|
|
3022
|
-
const outputPath =
|
|
2787
|
+
const outputPath = path11.join(researchDir, fileName);
|
|
3023
2788
|
const payload = {
|
|
3024
2789
|
meta: {
|
|
3025
2790
|
slug,
|
|
@@ -3043,21 +2808,24 @@ async function handleKeywordResearchRunner(input) {
|
|
|
3043
2808
|
"For any recommended keyword without scores, rerun get_keyword_scores to fill traffic/difficulty.",
|
|
3044
2809
|
"Keep rationale/nextActions in English by default unless you intentionally localize them."
|
|
3045
2810
|
],
|
|
3046
|
-
note: "Generated by keyword-research
|
|
2811
|
+
note: "Generated automatically by keyword-research"
|
|
3047
2812
|
},
|
|
3048
2813
|
data: { raw, summary }
|
|
3049
2814
|
};
|
|
3050
|
-
|
|
3051
|
-
await client.close();
|
|
2815
|
+
fs11.writeFileSync(outputPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
|
|
3052
2816
|
const lines = [];
|
|
3053
|
-
lines.push(`# keyword-research
|
|
2817
|
+
lines.push(`# keyword-research`);
|
|
3054
2818
|
lines.push(`Saved to ${outputPath}`);
|
|
3055
2819
|
lines.push(
|
|
3056
2820
|
`Candidates scored: ${scored.length}, Recommended: ${summary.recommendedKeywords.length}`
|
|
3057
2821
|
);
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
)
|
|
2822
|
+
if (scored.length < 10 || summary.recommendedKeywords.length < 10 || Object.values(raw.keywordSuggestions).some(
|
|
2823
|
+
(arr) => Array.isArray(arr) && arr.length === 0
|
|
2824
|
+
)) {
|
|
2825
|
+
lines.push(
|
|
2826
|
+
`Warning: results look sparse. Add more competitors/seeds and rerun.`
|
|
2827
|
+
);
|
|
2828
|
+
}
|
|
3061
2829
|
return {
|
|
3062
2830
|
content: [{ type: "text", text: lines.join("\n") }]
|
|
3063
2831
|
};
|
|
@@ -3112,13 +2880,6 @@ var tools = [
|
|
|
3112
2880
|
zodSchema: keywordResearchInputSchema,
|
|
3113
2881
|
handler: handleKeywordResearch,
|
|
3114
2882
|
category: "ASO Research"
|
|
3115
|
-
},
|
|
3116
|
-
{
|
|
3117
|
-
name: keywordResearchRunnerTool.name,
|
|
3118
|
-
description: keywordResearchRunnerTool.description,
|
|
3119
|
-
inputSchema: keywordResearchRunnerTool.inputSchema,
|
|
3120
|
-
handler: handleKeywordResearchRunner,
|
|
3121
|
-
category: "ASO Research"
|
|
3122
2883
|
}
|
|
3123
2884
|
];
|
|
3124
2885
|
function getToolDefinitions() {
|
|
@@ -3128,7 +2889,6 @@ function getToolDefinitions() {
|
|
|
3128
2889
|
improvePublicTool,
|
|
3129
2890
|
initProjectTool,
|
|
3130
2891
|
createBlogHtmlTool,
|
|
3131
|
-
keywordResearchRunnerTool,
|
|
3132
2892
|
keywordResearchTool
|
|
3133
2893
|
];
|
|
3134
2894
|
}
|