pabal-web-mcp 1.3.3 → 1.3.5

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.
@@ -2488,14 +2488,16 @@ function buildTemplate({
2488
2488
  plan: {
2489
2489
  steps: [
2490
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 competitors.",
2492
- "Discover competitors: search_app(term=seed keyword), get_similar_apps(appId=known competitor).",
2493
- "Collect candidates: suggest_keywords_by_seeds/by_category/by_similarity/by_competition/by_search + suggest_keywords_by_apps(apps=[top competitors]).",
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
2494
  "Score shortlist: get_keyword_scores for 15\u201330 candidates per platform/country.",
2495
- "Context check: analyze_reviews and fetch_reviews 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."
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."
2497
2499
  ],
2498
- 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)."
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."
2499
2501
  },
2500
2502
  data: {
2501
2503
  raw: {
@@ -2698,16 +2700,16 @@ Context around ${pos}: ${context}`
2698
2700
  `2) Confirm IDs/locales: get_app_details(appId from config/registered-apps) to lock locale/country and competitor list.`
2699
2701
  );
2700
2702
  lines.push(
2701
- `3) Discover apps: search_app(term=seed, platform=${platform}, country=${resolvedCountry}); get_similar_apps(appId=known competitor).`
2703
+ `3) Discover apps: search_app(term=seed, num=10-20, platform=${platform}, country=${resolvedCountry}); get_similar_apps(appId=top competitor, num=10).`
2702
2704
  );
2703
2705
  lines.push(
2704
- `4) Expand keywords: suggest_keywords_by_seeds/by_category/by_similarity/by_competition/by_search + suggest_keywords_by_apps(apps=[top competitors]).`
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]).`
2705
2707
  );
2706
2708
  lines.push(
2707
2709
  `5) Score shortlist: get_keyword_scores for 15\u201330 candidates (note: scores are heuristic per README).`
2708
2710
  );
2709
2711
  lines.push(
2710
- `6) Context check: analyze_reviews and fetch_reviews on top apps to harvest native phrasing; keep snippets for improve-public.`
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.`
2711
2713
  );
2712
2714
  lines.push(
2713
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>)`
@@ -2715,6 +2717,12 @@ Context around ${pos}: ${context}`
2715
2717
  lines.push(
2716
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.`
2717
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
+ );
2718
2726
  if (fileAction) {
2719
2727
  lines.push(`File: ${fileAction} at ${outputPath}`);
2720
2728
  } else {
@@ -2737,6 +2745,324 @@ Context around ${pos}: ${context}`
2737
2745
  };
2738
2746
  }
2739
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
+ if (!raw) return [];
2786
+ if (Array.isArray(raw)) {
2787
+ return raw.map((k) => k.trim()).filter(Boolean);
2788
+ }
2789
+ return raw.split(",").map((k) => k.trim()).filter(Boolean);
2790
+ }
2791
+ async function callToolJSON(client, name, args) {
2792
+ const result = await client.callTool({ name, arguments: args });
2793
+ const text = result.content?.[0]?.text;
2794
+ if (!text) return null;
2795
+ try {
2796
+ return JSON.parse(text);
2797
+ } catch {
2798
+ return text;
2799
+ }
2800
+ }
2801
+ function ensureDir(dir) {
2802
+ fs12.mkdirSync(dir, { recursive: true });
2803
+ }
2804
+ async function handleKeywordResearchRunner(input) {
2805
+ const {
2806
+ slug,
2807
+ locale,
2808
+ platform = "ios",
2809
+ country,
2810
+ seedKeywords = [],
2811
+ competitorApps = [],
2812
+ serverCwd,
2813
+ serverCommand,
2814
+ numSuggestions = 20
2815
+ } = input;
2816
+ const { config, locales } = loadProductLocales(slug);
2817
+ const primaryLocale = resolvePrimaryLocale(config, locales);
2818
+ const { app: registeredApp } = findRegisteredApp(slug);
2819
+ const resolvedCountry = country || (locale.includes("-") ? locale.split("-")[1].toLowerCase() : "us");
2820
+ const autoSeeds = [];
2821
+ const primaryLocaleData = locales[primaryLocale];
2822
+ if (primaryLocaleData?.aso?.title) autoSeeds.push(primaryLocaleData.aso.title);
2823
+ const parsedKeywords = normalizeKeywords2(primaryLocaleData?.aso?.keywords);
2824
+ autoSeeds.push(...parsedKeywords.slice(0, 5));
2825
+ if (config?.name) autoSeeds.push(config.name);
2826
+ if (config?.tagline) autoSeeds.push(config.tagline);
2827
+ if (registeredApp?.name) autoSeeds.push(registeredApp.name);
2828
+ if (platform === "ios" && registeredApp?.appStore?.name)
2829
+ autoSeeds.push(registeredApp.appStore.name);
2830
+ if (platform === "android" && registeredApp?.googlePlay?.name)
2831
+ autoSeeds.push(registeredApp.googlePlay.name);
2832
+ const seeds = Array.from(/* @__PURE__ */ new Set([...seedKeywords, ...autoSeeds])).slice(0, 10);
2833
+ const autoCompetitors = [];
2834
+ if (platform === "ios") {
2835
+ if (config?.appStoreAppId) {
2836
+ autoCompetitors.push({ appId: String(config.appStoreAppId), platform });
2837
+ } else if (config?.bundleId) {
2838
+ autoCompetitors.push({ appId: config.bundleId, platform });
2839
+ } else if (registeredApp?.appStore?.appId) {
2840
+ autoCompetitors.push({ appId: String(registeredApp.appStore.appId), platform });
2841
+ } else if (registeredApp?.appStore?.bundleId) {
2842
+ autoCompetitors.push({ appId: registeredApp.appStore.bundleId, platform });
2843
+ }
2844
+ } else {
2845
+ if (config?.packageName) {
2846
+ autoCompetitors.push({ appId: config.packageName, platform });
2847
+ } else if (registeredApp?.googlePlay?.packageName) {
2848
+ autoCompetitors.push({
2849
+ appId: registeredApp.googlePlay.packageName,
2850
+ platform
2851
+ });
2852
+ }
2853
+ }
2854
+ const competitors = Array.from(
2855
+ new Map(
2856
+ [...competitorApps, ...autoCompetitors].map((c) => [
2857
+ `${c.platform}:${c.appId}`,
2858
+ c
2859
+ ])
2860
+ ).values()
2861
+ ).slice(0, 5);
2862
+ const cwd = serverCwd || path12.resolve(process.cwd(), "external-tools", "mcp-appstore");
2863
+ const cmd = serverCommand?.[0] || "node";
2864
+ const args = serverCommand?.length ? serverCommand.slice(1) : ["server.js"];
2865
+ const transport = new StdioClientTransport({ command: cmd, args, cwd });
2866
+ const client = new Client(
2867
+ { name: "keyword-research-runner", version: "1.0.0" },
2868
+ { capabilities: { tools: {} } }
2869
+ );
2870
+ await client.connect(transport);
2871
+ const raw = {
2872
+ searchApp: [],
2873
+ getAppDetails: [],
2874
+ similarApps: [],
2875
+ keywordSuggestions: {
2876
+ bySeeds: [],
2877
+ byCategory: [],
2878
+ bySimilarity: [],
2879
+ byCompetition: [],
2880
+ bySearchHints: [],
2881
+ byApps: []
2882
+ },
2883
+ keywordScores: [],
2884
+ reviewsAnalysis: [],
2885
+ reviewsRaw: []
2886
+ };
2887
+ for (const term of seeds.slice(0, 3)) {
2888
+ const res = await callToolJSON(client, "search_app", {
2889
+ term,
2890
+ platform,
2891
+ num: 20,
2892
+ country: resolvedCountry
2893
+ });
2894
+ raw.searchApp.push({ query: term, platform, country: resolvedCountry, results: res?.results ?? res });
2895
+ }
2896
+ for (const comp of competitors.slice(0, 5)) {
2897
+ const res = await callToolJSON(client, "get_app_details", {
2898
+ appId: comp.appId,
2899
+ platform: comp.platform,
2900
+ country: resolvedCountry
2901
+ });
2902
+ raw.getAppDetails.push(res);
2903
+ }
2904
+ if (competitors.length > 0) {
2905
+ const first = competitors[0];
2906
+ const res = await callToolJSON(client, "get_similar_apps", {
2907
+ appId: first.appId,
2908
+ platform,
2909
+ num: 10,
2910
+ country: resolvedCountry
2911
+ });
2912
+ raw.similarApps = res?.similarApps || res || [];
2913
+ }
2914
+ const firstComp = competitors[0]?.appId;
2915
+ const appsList = competitors.map((c) => c.appId);
2916
+ const suggestCalls = [
2917
+ [
2918
+ "suggest_keywords_by_seeds",
2919
+ { keywords: seeds, platform, num: numSuggestions, country: resolvedCountry },
2920
+ "bySeeds"
2921
+ ],
2922
+ [
2923
+ "suggest_keywords_by_category",
2924
+ firstComp ? { appId: firstComp, platform, num: numSuggestions, country: resolvedCountry } : null,
2925
+ "byCategory"
2926
+ ],
2927
+ [
2928
+ "suggest_keywords_by_similarity",
2929
+ firstComp ? { appId: firstComp, platform, num: numSuggestions, country: resolvedCountry } : null,
2930
+ "bySimilarity"
2931
+ ],
2932
+ [
2933
+ "suggest_keywords_by_competition",
2934
+ firstComp ? { appId: firstComp, platform, num: numSuggestions, country: resolvedCountry } : null,
2935
+ "byCompetition"
2936
+ ],
2937
+ [
2938
+ "suggest_keywords_by_search",
2939
+ { keywords: seeds.slice(0, 5), platform, num: numSuggestions, country: resolvedCountry },
2940
+ "bySearchHints"
2941
+ ],
2942
+ [
2943
+ "suggest_keywords_by_apps",
2944
+ appsList.length ? { apps: appsList, platform, num: numSuggestions, country: resolvedCountry } : null,
2945
+ "byApps"
2946
+ ]
2947
+ ];
2948
+ for (const [tool, args2, key] of suggestCalls) {
2949
+ if (!args2) continue;
2950
+ const res = await callToolJSON(client, tool, args2);
2951
+ const suggestions = res?.suggestions || res || [];
2952
+ raw.keywordSuggestions[key] = suggestions;
2953
+ }
2954
+ for (const comp of competitors.slice(0, 2)) {
2955
+ const analysis = await callToolJSON(client, "analyze_reviews", {
2956
+ appId: comp.appId,
2957
+ platform: comp.platform,
2958
+ country: resolvedCountry,
2959
+ num: 150
2960
+ });
2961
+ raw.reviewsAnalysis.push(analysis);
2962
+ const rawReviews = await callToolJSON(client, "fetch_reviews", {
2963
+ appId: comp.appId,
2964
+ platform: comp.platform,
2965
+ country: resolvedCountry,
2966
+ num: 80
2967
+ });
2968
+ raw.reviewsRaw.push(rawReviews);
2969
+ }
2970
+ const candidateSet = /* @__PURE__ */ new Set();
2971
+ const addCandidates = (arr) => {
2972
+ if (!arr) return;
2973
+ if (Array.isArray(arr)) {
2974
+ arr.forEach((v) => {
2975
+ if (typeof v === "string") candidateSet.add(v);
2976
+ if (v?.keyword) candidateSet.add(String(v.keyword));
2977
+ if (v?.suggestions)
2978
+ addCandidates(v.suggestions);
2979
+ });
2980
+ }
2981
+ };
2982
+ seeds.forEach((s) => candidateSet.add(s));
2983
+ Object.values(raw.keywordSuggestions).forEach((arr) => addCandidates(arr));
2984
+ const candidates = Array.from(candidateSet).slice(0, 30);
2985
+ for (const kw of candidates) {
2986
+ const res = await callToolJSON(client, "get_keyword_scores", {
2987
+ keyword: kw,
2988
+ platform,
2989
+ country: resolvedCountry
2990
+ });
2991
+ raw.keywordScores.push(res);
2992
+ }
2993
+ const scored = raw.keywordScores.map((s) => {
2994
+ 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(s?.difficulty?.score ?? s?.scores?.difficulty?.score ?? "0") || 0;
2996
+ return {
2997
+ keyword: s.keyword || s?.scores?.keyword,
2998
+ trafficScore,
2999
+ difficultyScore,
3000
+ raw: s
3001
+ };
3002
+ }).filter((s) => s.keyword);
3003
+ const recommended = scored.sort((a, b) => b.trafficScore - a.trafficScore).slice(0, 15);
3004
+ const summary = {
3005
+ recommendedKeywords: recommended.map((r) => ({
3006
+ keyword: r.keyword,
3007
+ trafficScore: r.trafficScore,
3008
+ difficultyScore: r.difficultyScore
3009
+ })),
3010
+ rationale: "Sorted by traffic score (desc). Blend of core/high-traffic and mid/longtail technical terms.",
3011
+ nextActions: "Feed top 10\u201315 into improve-public Stage 1. If any sections are empty (suggestions/reviews), add more competitors/seeds and rerun."
3012
+ };
3013
+ const researchDir = path12.join(
3014
+ getKeywordResearchDir(),
3015
+ "products",
3016
+ slug,
3017
+ "locales",
3018
+ locale
3019
+ );
3020
+ ensureDir(researchDir);
3021
+ const fileName = `keyword-research-${platform}-${resolvedCountry}.json`;
3022
+ const outputPath = path12.join(researchDir, fileName);
3023
+ const payload = {
3024
+ meta: {
3025
+ slug,
3026
+ locale,
3027
+ platform,
3028
+ country: resolvedCountry,
3029
+ seedKeywords: seeds,
3030
+ competitorApps: competitors,
3031
+ source: "mcp-appstore",
3032
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3033
+ },
3034
+ plan: {
3035
+ steps: [
3036
+ "Start mcp-appstore server (node server.js in external-tools/mcp-appstore).",
3037
+ "Confirm app IDs/locales: get_app_details(appId from config/registered-apps) to lock country/lang and 3\u20135 competitors.",
3038
+ "Discover competitors: search_app(term=seed keyword, num=10\u201320), get_similar_apps(appId=top competitor, num=10).",
3039
+ "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]).",
3040
+ "Score shortlist: get_keyword_scores for 15\u201330 candidates per platform/country.",
3041
+ "Context check: analyze_reviews (num=100\u2013200) and fetch_reviews (num=50\u2013100) on top apps for language/tone cues.",
3042
+ "If keywordSuggestions/similar/reviews are sparse, rerun calls (add more competitors/seeds) until you have 10\u201315 strong keywords.",
3043
+ "For any recommended keyword without scores, rerun get_keyword_scores to fill traffic/difficulty.",
3044
+ "Keep rationale/nextActions in English by default unless you intentionally localize them."
3045
+ ],
3046
+ note: "Generated by keyword-research-runner"
3047
+ },
3048
+ data: { raw, summary }
3049
+ };
3050
+ fs12.writeFileSync(outputPath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
3051
+ await client.close();
3052
+ const lines = [];
3053
+ lines.push(`# keyword-research-runner`);
3054
+ lines.push(`Saved to ${outputPath}`);
3055
+ lines.push(
3056
+ `Candidates scored: ${scored.length}, Recommended: ${summary.recommendedKeywords.length}`
3057
+ );
3058
+ lines.push(
3059
+ `Warnings: rerun if keywordSuggestions are empty or recommended < 10.`
3060
+ );
3061
+ return {
3062
+ content: [{ type: "text", text: lines.join("\n") }]
3063
+ };
3064
+ }
3065
+
2740
3066
  // src/tools/index.ts
2741
3067
  var tools = [
2742
3068
  {
@@ -2786,6 +3112,13 @@ var tools = [
2786
3112
  zodSchema: keywordResearchInputSchema,
2787
3113
  handler: handleKeywordResearch,
2788
3114
  category: "ASO Research"
3115
+ },
3116
+ {
3117
+ name: keywordResearchRunnerTool.name,
3118
+ description: keywordResearchRunnerTool.description,
3119
+ inputSchema: keywordResearchRunnerTool.inputSchema,
3120
+ handler: handleKeywordResearchRunner,
3121
+ category: "ASO Research"
2789
3122
  }
2790
3123
  ];
2791
3124
  function getToolDefinitions() {
@@ -2795,6 +3128,7 @@ function getToolDefinitions() {
2795
3128
  improvePublicTool,
2796
3129
  initProjectTool,
2797
3130
  createBlogHtmlTool,
3131
+ keywordResearchRunnerTool,
2798
3132
  keywordResearchTool
2799
3133
  ];
2800
3134
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pabal-web-mcp",
3
- "version": "1.3.3",
3
+ "version": "1.3.5",
4
4
  "type": "module",
5
5
  "description": "MCP server for ASO data management with shared types and utilities",
6
6
  "author": "skyu",