@appeeky/spec 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.github/assets/cover.gif +0 -0
  2. package/LICENSE +110 -0
  3. package/README.md +636 -2
  4. package/agents/appeeky-ads.md +50 -0
  5. package/agents/appeeky-aso.md +58 -0
  6. package/agents/appeeky-releases.md +47 -0
  7. package/agents/appeeky-reviews.md +53 -0
  8. package/agents/appeeky-social.md +54 -0
  9. package/agents/appeeky-ugc.md +56 -0
  10. package/agents/cofounder.md +36 -0
  11. package/dist/ads.d.ts +42 -0
  12. package/dist/ads.js +71 -0
  13. package/dist/agents.d.ts +47 -0
  14. package/dist/agents.js +88 -0
  15. package/dist/appeeky-api.d.ts +62 -0
  16. package/dist/appeeky-api.js +240 -0
  17. package/dist/attribution-setup.d.ts +28 -0
  18. package/dist/attribution-setup.js +65 -0
  19. package/dist/attribution.d.ts +121 -0
  20. package/dist/attribution.js +170 -0
  21. package/dist/bin.d.ts +2 -0
  22. package/dist/bin.js +4 -0
  23. package/dist/category.d.ts +71 -0
  24. package/dist/category.js +198 -0
  25. package/dist/cli.d.ts +3 -0
  26. package/dist/cli.js +1960 -0
  27. package/dist/competitors.d.ts +74 -0
  28. package/dist/competitors.js +143 -0
  29. package/dist/control.d.ts +21 -0
  30. package/dist/control.js +53 -0
  31. package/dist/core.d.ts +15 -0
  32. package/dist/core.js +15 -0
  33. package/dist/coverage.d.ts +46 -0
  34. package/dist/coverage.js +115 -0
  35. package/dist/data.d.ts +48 -0
  36. package/dist/data.js +298 -0
  37. package/dist/dataforseo.d.ts +139 -0
  38. package/dist/dataforseo.js +354 -0
  39. package/dist/diff.d.ts +11 -0
  40. package/dist/diff.js +33 -0
  41. package/dist/experiments.d.ts +115 -0
  42. package/dist/experiments.js +85 -0
  43. package/dist/files.d.ts +27 -0
  44. package/dist/files.js +129 -0
  45. package/dist/history.d.ts +43 -0
  46. package/dist/history.js +55 -0
  47. package/dist/index.d.ts +24 -0
  48. package/dist/index.js +24 -0
  49. package/dist/limits.d.ts +15 -0
  50. package/dist/limits.js +30 -0
  51. package/dist/lint.d.ts +16 -0
  52. package/dist/lint.js +159 -0
  53. package/dist/loops.d.ts +37 -0
  54. package/dist/loops.js +197 -0
  55. package/dist/measure.d.ts +40 -0
  56. package/dist/measure.js +159 -0
  57. package/dist/metrics.d.ts +290 -0
  58. package/dist/metrics.js +254 -0
  59. package/dist/output.d.ts +21 -0
  60. package/dist/output.js +79 -0
  61. package/dist/project.d.ts +19 -0
  62. package/dist/project.js +35 -0
  63. package/dist/report.d.ts +10 -0
  64. package/dist/report.js +113 -0
  65. package/dist/reviews.d.ts +130 -0
  66. package/dist/reviews.js +287 -0
  67. package/dist/schema.d.ts +612 -0
  68. package/dist/schema.js +253 -0
  69. package/dist/screenshots.d.ts +23 -0
  70. package/dist/screenshots.js +116 -0
  71. package/dist/search-ads.d.ts +137 -0
  72. package/dist/search-ads.js +354 -0
  73. package/dist/seo.d.ts +87 -0
  74. package/dist/seo.js +391 -0
  75. package/dist/setup.d.ts +45 -0
  76. package/dist/setup.js +259 -0
  77. package/dist/social.d.ts +129 -0
  78. package/dist/social.js +189 -0
  79. package/dist/storefronts.d.ts +15 -0
  80. package/dist/storefronts.js +89 -0
  81. package/dist/stores/app-store.d.ts +33 -0
  82. package/dist/stores/app-store.js +159 -0
  83. package/dist/stores/google-play.d.ts +13 -0
  84. package/dist/stores/google-play.js +61 -0
  85. package/dist/stores/types.d.ts +34 -0
  86. package/dist/stores/types.js +2 -0
  87. package/dist/suggest.d.ts +73 -0
  88. package/dist/suggest.js +135 -0
  89. package/dist/text.d.ts +18 -0
  90. package/dist/text.js +55 -0
  91. package/dist/transport.d.ts +99 -0
  92. package/dist/transport.js +170 -0
  93. package/dist/ugc-core.js +1 -0
  94. package/dist/ugc-local.d.ts +116 -0
  95. package/dist/ugc-local.js +174 -0
  96. package/dist/ugc.d.ts +149 -0
  97. package/dist/ugc.js +111 -0
  98. package/dist/verify.d.ts +70 -0
  99. package/dist/verify.js +134 -0
  100. package/dist/visibility-core.js +96 -0
  101. package/dist/visibility-local.d.ts +159 -0
  102. package/dist/visibility-local.js +246 -0
  103. package/dist/visibility.d.ts +152 -0
  104. package/dist/visibility.js +239 -0
  105. package/package.json +80 -4
  106. package/skills/appeeky-spec/SKILL.md +142 -0
  107. package/skills/appeeky-spec-experiments/SKILL.md +93 -0
  108. package/skills/appeeky-spec-localize/SKILL.md +52 -0
  109. package/skills/appeeky-spec-review/SKILL.md +75 -0
  110. package/skills/appeeky-spec-targets/SKILL.md +66 -0
  111. package/skills/appeeky-spec-visibility/SKILL.md +106 -0
@@ -0,0 +1,96 @@
1
+ function f(t){return!!t&&typeof t=="object"&&!Array.isArray(t)}function w(t){return Array.isArray(t)?t:[]}function h(t){return typeof t=="string"&&t.length>0?t:void 0}function k(t){return typeof t=="number"&&Number.isFinite(t)?t:0}function W(t){try{let n=new URL(t);n.hash="",n.searchParams.delete("utm_source");let e=n.toString();return e.endsWith("/")&&(e=e.slice(0,-1)),e.toLowerCase()}catch{return t.trim().toLowerCase()}}var A=class{list=[];index=new Map;add(n,e){if(!n)return null;let r=W(n),o=this.index.get(r);if(o!==void 0){let i=this.list[o];return!i.title&&e&&(i.title=e),o+1}return this.list.push(e?{url:n,title:e}:{url:n}),this.index.set(r,this.list.length-1),this.list.length}get citations(){return this.list}};function S(t){return Array.from(new Set(t)).sort((e,r)=>e-r).map(e=>` [${e}]`).join("")}function E(t){let n=f(t)?t:{},e=new A,r=[],o=0;for(let a of w(n.output))if(f(a)){if(a.type==="web_search_call"){o+=1;continue}if(a.type==="message")for(let p of w(a.content)){if(!f(p)||p.type!=="output_text")continue;let u=typeof p.text=="string"?p.text:"",l=[];for(let c of w(p.annotations)){if(!f(c)||c.type!=="url_citation")continue;let m=h(c.url),g=e.add(m,h(c.title));if(g===null||!m)continue;let d=G(u,c,m);d!==null&&l.push({at:d,n:g})}r.push(F(u,l))}}let i=r.join(`
2
+
3
+ `).trim();!i&&typeof n.output_text=="string"&&(i=n.output_text.trim());let s=f(n.usage)?n.usage:{};return{text:i,citations:e.citations,inputTokens:k(s.input_tokens),outputTokens:k(s.output_tokens),searchCalls:o}}function G(t,n,e){let r=typeof n.start_index=="number"?n.start_index:null,o=typeof n.end_index=="number"?n.end_index:null,i="";try{i=new URL(e).hostname.replace(/^www\./,"")}catch{}if(r!==null&&o!==null&&o<=t.length&&r<o){let p=t.slice(r,o);if(p.includes(e)||i&&p.includes(i))return o}let s=t.indexOf(e);if(s===-1)return null;let a=s+e.length;for(;a<t.length&&t[a]===")";)a+=1;return a}function F(t,n){if(n.length===0)return t;let e=new Map;for(let{at:o,n:i}of n)e.set(o,[...e.get(o)??[],i]);let r=t;for(let o of Array.from(e.keys()).sort((i,s)=>s-i))r=r.slice(0,o)+S(e.get(o))+r.slice(o);return r}function T(t){let n=f(t)?t:{},e=new A,r=w(n.content).filter(f),o="",i=0;for(let u of r){if(u.type==="server_tool_use"&&u.name==="web_search"&&(i+=1),u.type!=="text")continue;let l=typeof u.text=="string"?u.text:"",c=[];for(let m of w(u.citations)){if(!f(m)||m.type!==void 0&&m.type!=="web_search_result_location")continue;let g=e.add(h(m.url),h(m.title));g!==null&&c.push(g)}o+=c.length>0?J(l,S(c)):l}for(let u of r)if(u.type==="web_search_tool_result")for(let l of w(u.content))f(l)&&(l.type!==void 0&&l.type!=="web_search_result"||e.add(h(l.url),h(l.title)));let s=f(n.usage)?n.usage:{},a=f(s.server_tool_use)?s.server_tool_use:{},p=k(a.web_search_requests);return{text:o.trim(),citations:e.citations,inputTokens:k(s.input_tokens),outputTokens:k(s.output_tokens),searchCalls:p>0?p:i}}function J(t,n){let e=/\s*$/.exec(t),r=e?e.index:t.length;return t.slice(0,r)+n+t.slice(r)}function O(t){let n=new A,e=f(t.groundingMetadata)?t.groundingMetadata:{},r=[];for(let s of w(e.groundingChunks)){let a=f(s)&&f(s.web)?s.web:f(s)&&f(s.retrievedContext)?s.retrievedContext:null;r.push(a?n.add(h(a.uri),h(a.title)):null)}for(let s of t.sources??[])n.add(h(s?.url),h(s?.title));let o=t.text;for(let s of w(e.groundingSupports)){if(!f(s))continue;let a=f(s.segment)?s.segment:{},p=h(a.text)??h(s.segment_text);if(!p)continue;let u=w(s.groundingChunkIndices).map(m=>typeof m=="number"?r[m]:null).filter(m=>typeof m=="number");if(u.length===0)continue;let l=o.indexOf(p);if(l===-1)continue;let c=l+p.length;o=o.slice(0,c)+S(u)+o.slice(c)}let i=w(e.webSearchQueries).length;return{text:o,citations:n.citations,searchCalls:i>0||n.citations.length>0?1:0}}var R=["chatgpt","claude","gemini","perplexity"],$={chatgpt:{model:"chatgpt",env:["OPENAI_API_KEY"],modelId:"gpt-4.1",estimateUsd:.035},claude:{model:"claude",env:["ANTHROPIC_API_KEY"],modelId:"claude-haiku-5-5",estimateUsd:.015},gemini:{model:"gemini",env:["GEMINI_API_KEY","GOOGLE_API_KEY","GOOGLE_GENERATIVE_AI_API_KEY"],modelId:"gemini-2.5-flash",estimateUsd:.04},perplexity:{model:"perplexity",env:["PERPLEXITY_API_KEY"],modelId:"sonar",estimateUsd:.008}},B=[{model:"chatgpt",modelId:"gpt-4.1-mini",estimateUsd:.001},{model:"gemini",modelId:"gemini-2.5-flash",estimateUsd:.001},{model:"claude",modelId:"claude-haiku-5-5",estimateUsd:.002}];function j(t,n){for(let e of $[t].env)if(n[e]?.trim())return n[e].trim()}function nt(t,n=R){return n.filter(e=>j(e,t))}function D(t){for(let n of B){let e=j(n.model,t);if(e)return{...n,key:e}}return null}function st(t,n,e){let r=D(e),o=n.reduce((i,s)=>i+$[s].estimateUsd+(r?.estimateUsd??0),0);return Math.round(t*o*100)/100}var Y=t=>new Promise(n=>setTimeout(n,t));async function b(t,n,e,r,o=12e4){let i="";for(let s=0;s<4;s++){let a=await t(n,{method:"POST",headers:{"content-type":"application/json",...e},body:JSON.stringify(r),signal:AbortSignal.timeout(o)}),p=await a.text();if(a.ok)return JSON.parse(p);if(i=`${a.status} ${q(p)}`,a.status!==429&&a.status<500)break;let u=Number(a.headers.get("retry-after"));await Y(Number.isFinite(u)&&u>0?Math.min(u*1e3,3e4):2e3*2**s)}throw new Error(i)}function q(t){try{let n=JSON.parse(t),e=Array.isArray(n)?n[0]?.error:n.error;return(typeof e=="string"?e:e?.message)??t.slice(0,200)}catch{return t.slice(0,200)}}var H={"gpt-4.1":{in:2,out:8},"gpt-4.1-mini":{in:.4,out:1.6},"claude-haiku-5-5":{in:.1,out:.5},"gemini-2.5-flash":{in:.3,out:2.5},sonar:{in:1,out:1}},C={chatgpt:.025,claude:.01,gemini:.035,perplexity:.005},x=(t,n,e)=>{let r=H[t];return r?(n*r.in+e*r.out)/1e6:0};function K(t,n){return`You are a helpful assistant recommending mobile apps.
4
+ The user is in ${t.toUpperCase()} and writes in ${n.toUpperCase()}.
5
+ When the user asks for an app, return a short ranked list (1\u20135) of real,
6
+ currently-available apps. For each app: state its name first, then a
7
+ one-sentence reason. Prefer iOS App Store apps. Be specific \u2014 name the
8
+ exact app, not a category. If you genuinely cannot recommend any app,
9
+ say so plainly.`}function z(t){let n=t.toLowerCase();return["i can't recommend","i cannot recommend","i'm unable to recommend","i am unable to recommend"].some(e=>n.includes(e))||n.startsWith("i'm sorry")||n.startsWith("sorry, i")}var L=t=>/claude-[a-z]+-5/.test(t)?{max_tokens:16e3}:{max_tokens:1500,temperature:.4};async function ot(t,n,e,r=fetch){let o=$[t].modelId,i=K(e.country,e.language);try{let s="",a=[],p=0;if(t==="chatgpt"){let u=await b(r,"https://api.openai.com/v1/responses",{authorization:`Bearer ${n}`},{model:o,instructions:i,input:e.prompt,temperature:.4,tools:[{type:"web_search_preview",search_context_size:"low"}]}),l=E(u);({text:s,citations:a}=l),p=x(o,l.inputTokens,l.outputTokens)+C.chatgpt*Math.max(1,l.searchCalls)}else if(t==="claude"){let u=await b(r,"https://api.anthropic.com/v1/messages",{"x-api-key":n,"anthropic-version":"2023-06-01"},{model:o,...L(o),system:i,messages:[{role:"user",content:e.prompt}],tools:[{type:"web_search_20250305",name:"web_search",max_uses:3}]}),l=T(u);({text:s,citations:a}=l),p=x(o,l.inputTokens,l.outputTokens)+C.claude*Math.max(1,l.searchCalls)}else if(t==="gemini"){let u=await b(r,`https://generativelanguage.googleapis.com/v1beta/models/${o}:generateContent`,{"x-goog-api-key":n},{systemInstruction:{parts:[{text:i}]},contents:[{role:"user",parts:[{text:e.prompt}]}],tools:[{google_search:{}}],generationConfig:{temperature:.4}}),l=u?.candidates?.[0],c=(l?.content?.parts??[]).map(d=>d.text??"").join(""),m=O({text:c,groundingMetadata:l?.groundingMetadata});({text:s,citations:a}=m);let g=u?.usageMetadata??{};p=x(o,g.promptTokenCount??0,(g.candidatesTokenCount??0)+(g.thoughtsTokenCount??0))+(m.searchCalls>0?C.gemini:0)}else{let u=await b(r,"https://api.perplexity.ai/chat/completions",{authorization:`Bearer ${n}`},{model:o,temperature:.4,messages:[{role:"system",content:i},{role:"user",content:e.prompt}]});s=u?.choices?.[0]?.message?.content??"",a=(Array.isArray(u?.search_results)&&u.search_results.length?u.search_results:Array.isArray(u?.citations)?u.citations:[]).map(c=>typeof c=="string"?{url:c}:c&&typeof c.url=="string"?{url:c.url,title:c.title}:null).filter(c=>c!==null),p=x(o,u?.usage?.prompt_tokens??0,u?.usage?.completion_tokens??0)+C.perplexity}return{model:t,modelId:o,status:z(s)?"refusal":"ok",text:s,citations:a,costUsd:p}}catch(s){return{model:t,modelId:o,status:"error",text:"",citations:[],costUsd:0,error:s.message}}}function v(t){let n=t.indexOf("{"),e=t.lastIndexOf("}");if(n===-1||e<=n)throw new Error("no JSON in the answer");return JSON.parse(t.slice(n,e+1))}async function P(t,n,e,r=fetch){if(t.model==="chatgpt"){let s=await b(r,"https://api.openai.com/v1/chat/completions",{authorization:`Bearer ${t.key}`},{model:t.modelId,temperature:e,response_format:{type:"json_object"},messages:[{role:"user",content:n}]});return{json:v(s?.choices?.[0]?.message?.content??""),costUsd:x(t.modelId,s?.usage?.prompt_tokens??0,s?.usage?.completion_tokens??0)}}if(t.model==="gemini"){let s=await b(r,`https://generativelanguage.googleapis.com/v1beta/models/${t.modelId}:generateContent`,{"x-goog-api-key":t.key},{contents:[{role:"user",parts:[{text:n}]}],generationConfig:{temperature:e,responseMimeType:"application/json"}}),a=(s?.candidates?.[0]?.content?.parts??[]).map(u=>u.text??"").join(""),p=s?.usageMetadata??{};return{json:v(a),costUsd:x(t.modelId,p.promptTokenCount??0,(p.candidatesTokenCount??0)+(p.thoughtsTokenCount??0))}}let o=await b(r,"https://api.anthropic.com/v1/messages",{"x-api-key":t.key,"anthropic-version":"2023-06-01"},{model:t.modelId,...L(t.modelId),messages:[{role:"user",content:`${n}
10
+
11
+ Answer with the JSON object only.`}]}),i=(o?.content??[]).filter(s=>s.type==="text").map(s=>s.text).join("");return{json:v(i),costUsd:x(t.modelId,o?.usage?.input_tokens??0,o?.usage?.output_tokens??0)}}function V(t){let n=t.competitors.length?`
12
+ Known competitor apps: ${t.competitors.slice(0,10).join(", ")}`:"",e=t.citations.length?`
13
+
14
+ Citations available (0-indexed):
15
+ ${t.citations.slice(0,10).map((r,o)=>` [${o}] ${r.title??r.url}`).join(`
16
+ `)}`:"";return`You parse a recommendation answer from an AI assistant and extract
17
+ which mobile apps were mentioned, and in which position.
18
+
19
+ Original user prompt:
20
+ """${t.prompt}"""
21
+
22
+ The user owns this app (recognise it even with minor spelling variants):
23
+ "${t.owner}"${n}
24
+
25
+ Assistant's answer:
26
+ """${t.answer.slice(0,8e3)}"""${e}
27
+
28
+ Return JSON: {"refusal": boolean, "apps": [{"appName": string, "position": number|null, "citationIndex": number|null}]}
29
+
30
+ Rules:
31
+ - "apps" lists each app the assistant mentioned in their recommendation,
32
+ in the order the assistant presented them.
33
+ - position: 1-based rank when the assistant gave a numbered or ordered
34
+ list. null when the answer is conversational prose with no clear ranking.
35
+ - citationIndex: if the assistant cited source [N] for that app, return
36
+ N-1 (0-indexed into the Citations list above). null otherwise.
37
+ - If the assistant refused or said it couldn't recommend apps, set
38
+ refusal=true and apps=[].
39
+ - Do NOT invent apps the assistant didn't mention. Do NOT include
40
+ generic phrases like "an app for X" \u2014 only specific named apps.
41
+ - Strip explanatory parentheses from appName (return "Calm" not
42
+ "Calm (popular meditation app)").
43
+ - App names should be the brand the assistant used.`}async function rt(t,n,e=fetch){let{json:r,costUsd:o}=await P(t,V(n),0,e),s=(Array.isArray(r?.apps)?r.apps:[]).map(a=>a).filter(a=>typeof a.appName=="string"&&a.appName.trim()).map(a=>({name:a.appName.trim(),position:typeof a.position=="number"&&a.position>=1?Math.round(a.position):null,citation:typeof a.citationIndex=="number"&&a.citationIndex>=0?Math.round(a.citationIndex):null}));return{refusal:!!r?.refusal,mentions:s,costUsd:o}}var _=t=>t.toLowerCase().replace(/[^\p{L}\p{N}]/gu,"");function N(t,n){let e=_(t);return e?n.some(r=>[r,r.split(/\s[:\-–—|]\s|:\s/)[0]??r].map(_).filter(i=>i.length>=3).some(i=>e===i||i.length>=4&&(e.includes(i)||i.includes(e)&&e.length>=i.length*.7))):!1}var X=25,I=t=>Math.round(t*10)/10;function it(t,n,e){return n.map(r=>{let o=t.filter(c=>c.intent===r&&(c.status==="ok"||c.status==="refusal")),i={};for(let c of R){let m=o.filter(y=>y.model===c);if(!m.length)continue;let g=m.map(y=>y.mentions.find(M=>N(M.name,e))).filter(y=>!!y),d=g.map(y=>y.position).filter(y=>y!==null);i[c]={pct:I(g.length/m.length*100),position:d.length?I(d.reduce((y,M)=>y+M,0)/d.length):null,prompts:m.length}}let s=Object.values(i),a=s.length?I(s.reduce((c,m)=>c+m.pct,0)/s.length):void 0,p=new Map;for(let c of o)for(let m of c.mentions){if(N(m.name,e))continue;let g=_(m.name),d=p.get(g)??{name:m.name,count:0,models:new Set};d.count++,d.models.add(c.model),p.set(g,d)}let u=[...p.values()].sort((c,m)=>m.count-c.count||c.name.localeCompare(m.name)).slice(0,5).map(c=>({name:c.name,pct:o.length?Math.min(100,I(c.count/o.length*100)):0,models:[...c.models]})),l=a===void 0?"no_data":a>=X?"winning":a>0?"weak":"missing";return{label:r,status:l,you:a,byModel:i,namedInstead:u}})}function at(t,n){let e=n.map(o=>o.toLowerCase().replace(/^www\./,"")),r=new Map;for(let o of t)for(let i of o.citations){let s;try{s=new URL(i.url).hostname.toLowerCase().replace(/^www\./,"")}catch{continue}s.endsWith("vertexaisearch.cloud.google.com")&&i.title&&/^[\w.-]+\.[a-z]{2,}$/i.test(i.title)&&(s=i.title.toLowerCase().replace(/^www\./,""));let a=r.get(s)??{domain:s,count:0,url:i.url};a.count++,e.some(p=>s===p||s.endsWith(`.${p}`))&&(a.yours=!0),r.set(s,a)}return[...r.values()].sort((o,i)=>i.count-o.count||o.domain.localeCompare(i.domain))}function Q(t,n,e){return`You map a mobile app to the *user intents* it serves \u2014 the goals
44
+ real users would ask an AI assistant (like ChatGPT, Gemini, or
45
+ Perplexity) about. Each intent is a short user-meaningful goal,
46
+ NOT an app feature.
47
+
48
+ App: ${t.title}${t.subtitle?`
49
+ Subtitle: ${t.subtitle}`:""}${t.description?`
50
+ Description (first 700 chars): ${t.description.slice(0,700)}`:""}${t.keywords?.length?`
51
+ Target keywords: ${t.keywords.slice(0,20).join(", ")}`:""}
52
+
53
+ Country: ${t.country.toUpperCase()}
54
+ Language: ${t.language.toUpperCase()}${e.length?`
55
+
56
+ ALREADY DEFINED (do NOT propose duplicates or near-duplicates):
57
+ - ${e.slice(0,30).join(`
58
+ - `)}`:""}
59
+
60
+ Return JSON: {"intents": [{"label": string, "description": string}]}
61
+
62
+ Rules:
63
+ - Propose between 4 and ${n} distinct intents.
64
+ - Phrase each intent from the USER's point of view, in sentence-case,
65
+ starting with a verb when natural ("Fall asleep faster and unwind"). 4\u201310 words.
66
+ - Each intent must be something a real user would type into an AI
67
+ assistant \u2014 NOT marketing copy and NOT a literal feature name.
68
+ - Cover a healthy mix: core use-cases, related needs the app can satisfy,
69
+ problem-driven goals, and at least one comparison/alternative angle when applicable.
70
+ - Write labels in the storefront's language.
71
+ - Description (1 sentence, max 200 chars): what kind of user has this
72
+ intent and what they want the assistant to recommend.`}function Z(t,n,e){return`You write realistic prompts that everyday users would type into an
73
+ AI assistant (ChatGPT, Gemini, Perplexity) when looking for a mobile app.
74
+
75
+ Intent to cover:
76
+ "${n.label}"${n.description?`
77
+ ${n.description}`:""}
78
+
79
+ App context (the app whose visibility we're measuring):
80
+ Title: ${t.title}${t.subtitle?`
81
+ Subtitle: ${t.subtitle}`:""}${t.competitors?.length?`
82
+ Known competitor apps (use in comparison prompts where natural): ${t.competitors.slice(0,6).join(", ")}`:""}
83
+
84
+ Country: ${t.country.toUpperCase()}
85
+ Language: ${t.language.toUpperCase()}
86
+
87
+ Return JSON: {"prompts": [string]}
88
+
89
+ Rules:
90
+ - Produce ${e} prompts that cover the intent from different angles:
91
+ a pure category query, a problem-first one ("I struggle with\u2026"),
92
+ a scenario ("an app for my morning routine"), and where natural an
93
+ "alternative to X" comparison. Never name the app being measured.
94
+ - Write the way a casual user types, in the storefront's language:
95
+ conversational, lower-case is fine. Length 8\u201335 words.
96
+ - Do NOT mention that you're an AI or test prompts. No emojis.`}var U=t=>t.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu," ").split(/\s+/).filter(Boolean).sort().join(" ");async function ct(t,n,e={},r=fetch){let o=e.maxIntents??6,i=await P(t,Q(n,o,e.exclude??[]),.7,r),s=i.costUsd,a=new Set((e.exclude??[]).map(U)),p=[];for(let l of Array.isArray(i.json?.intents)?i.json.intents:[]){if(typeof l?.label!="string"||l.label.trim().length<4)continue;let c=U(l.label);if(!a.has(c)&&(a.add(c),p.push({label:l.label.trim(),description:typeof l.description=="string"?l.description.trim():void 0}),p.length>=o))break}let u=[];for(let l of p){let c=await P(t,Z(n,l,e.promptsPerIntent??4),.8,r);s+=c.costUsd;let m=_(n.title.split(/[:\-–—|]/)[0]??n.title),g=[...new Set((Array.isArray(c.json?.prompts)?c.json.prompts:[]).filter(d=>typeof d=="string"&&d.trim().length>=15).map(d=>d.trim()))].filter(d=>m.length<4||!_(d).includes(m)).slice(0,e.promptsPerIntent??4);g.length&&u.push({...l,prompts:g})}return{intents:u,costUsd:s}}export{R as MODELS,$ as PROVIDERS,X as WINNING_PCT,ot as ask,nt as availableModels,at as citedDomains,rt as extract,ct as generateIntents,D as helperFor,P as helperJson,N as isOwner,j as keyFor,st as quoteScan,it as score};
@@ -0,0 +1,159 @@
1
+ import type { MetricSource } from "./metrics.js";
2
+ import type { Project } from "./schema.js";
3
+ import { type AiIntent, type Model, type VisibilityFile } from "./visibility.js";
4
+ /**
5
+ * AI visibility on your own model keys (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY,
6
+ * PERPLEXITY_API_KEY: any of them). The prompts in appeeky.spec.yaml go to each assistant with web
7
+ * search on, a cheap second call lists the apps each answer names, and the result is written to the
8
+ * same appeeky/visibility/<country>.yaml that `visibility pull` writes. The providers bill you
9
+ * directly; no Appeeky account is needed. The pipeline ships inside Spec as dist/visibility-core.js.
10
+ */
11
+ type Env = Record<string, string | undefined>;
12
+ interface Citation {
13
+ url: string;
14
+ title?: string;
15
+ }
16
+ interface Answer {
17
+ model: Model;
18
+ modelId: string;
19
+ status: "ok" | "refusal" | "error";
20
+ text: string;
21
+ citations: Citation[];
22
+ costUsd: number;
23
+ error?: string;
24
+ }
25
+ interface Mention {
26
+ name: string;
27
+ position: number | null;
28
+ citation: number | null;
29
+ }
30
+ interface Helper {
31
+ model: Model;
32
+ modelId: string;
33
+ key: string;
34
+ estimateUsd: number;
35
+ }
36
+ interface ScoredAnswer {
37
+ intent: string;
38
+ prompt: string;
39
+ model: Model;
40
+ status: Answer["status"];
41
+ mentions: Mention[];
42
+ citations: Citation[];
43
+ }
44
+ interface IntentScore {
45
+ label: string;
46
+ status: "missing" | "weak" | "winning" | "no_data";
47
+ you?: number;
48
+ byModel: Partial<Record<Model, {
49
+ pct: number;
50
+ position: number | null;
51
+ prompts: number;
52
+ }>>;
53
+ namedInstead: Array<{
54
+ name: string;
55
+ pct: number;
56
+ models: Model[];
57
+ }>;
58
+ }
59
+ export interface AppContext {
60
+ title: string;
61
+ subtitle?: string;
62
+ description?: string;
63
+ keywords?: string[];
64
+ competitors?: string[];
65
+ country: string;
66
+ language: string;
67
+ }
68
+ export interface VisibilityCore {
69
+ MODELS: Model[];
70
+ PROVIDERS: Record<Model, {
71
+ env: string[];
72
+ modelId: string;
73
+ estimateUsd: number;
74
+ }>;
75
+ keyFor(model: Model, env: Env): string | undefined;
76
+ availableModels(env: Env, wanted?: Model[]): Model[];
77
+ helperFor(env: Env): Helper | null;
78
+ quoteScan(prompts: number, models: Model[], env: Env): number;
79
+ ask(model: Model, key: string, input: {
80
+ prompt: string;
81
+ country: string;
82
+ language: string;
83
+ }, fetcher?: typeof fetch): Promise<Answer>;
84
+ extract(helper: Helper, input: {
85
+ answer: string;
86
+ prompt: string;
87
+ owner: string;
88
+ competitors: string[];
89
+ citations: Citation[];
90
+ }, fetcher?: typeof fetch): Promise<{
91
+ refusal: boolean;
92
+ mentions: Mention[];
93
+ costUsd: number;
94
+ }>;
95
+ isOwner(name: string, titles: string[]): boolean;
96
+ score(answers: ScoredAnswer[], intents: string[], owner: string[]): IntentScore[];
97
+ citedDomains(answers: ScoredAnswer[], ownDomains: string[]): Array<{
98
+ domain: string;
99
+ count: number;
100
+ url: string;
101
+ yours?: true;
102
+ }>;
103
+ generateIntents(helper: Helper, app: AppContext, opts?: {
104
+ maxIntents?: number;
105
+ promptsPerIntent?: number;
106
+ exclude?: string[];
107
+ }, fetcher?: typeof fetch): Promise<{
108
+ intents: Array<{
109
+ label: string;
110
+ description?: string;
111
+ prompts: string[];
112
+ }>;
113
+ costUsd: number;
114
+ }>;
115
+ }
116
+ export declare const VISIBILITY_CORE_FILE = "./visibility-core.js";
117
+ export declare function loadVisibilityCore(load?: (specifier: string) => Promise<Record<string, unknown>>): Promise<VisibilityCore>;
118
+ export declare const KEY_NAMES = "OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY or PERPLEXITY_API_KEY";
119
+ /** The listing the storefront shows, as context for intents and prompts. */
120
+ export declare function appContext(project: Project, country: string): AppContext;
121
+ /** Every name the assistants may use for the app. */
122
+ export declare function ownerNames(project: Project): string[];
123
+ /** The app's own domains: its website and the listing's marketing and support URLs. */
124
+ export declare function ownDomains(project: Project): string[];
125
+ export interface LocalScanPlan {
126
+ country: string;
127
+ intents: AiIntent[];
128
+ prompts: number;
129
+ models: Model[];
130
+ helper: Helper | null;
131
+ quoteUsd: number;
132
+ }
133
+ export declare function planLocalScan(core: VisibilityCore, project: Project, country: string, env: Env): LocalScanPlan;
134
+ export declare const historyFile: (root: string) => string;
135
+ export declare const answersFile: (root: string, country: string) => string;
136
+ export interface LocalScanResult {
137
+ country: string;
138
+ file: string;
139
+ data: VisibilityFile;
140
+ changes: string[];
141
+ costUsd: number;
142
+ answered: number;
143
+ failed: Array<{
144
+ model: Model;
145
+ error: string;
146
+ }>;
147
+ }
148
+ /** Ask every assistant every prompt for one storefront, then write the snapshot, the answers and a history row per intent and assistant. */
149
+ export declare function scanLocal(core: VisibilityCore, project: Project, plan: LocalScanPlan, env: Env, options?: {
150
+ fetcher?: typeof fetch;
151
+ now?: Date;
152
+ concurrency?: number;
153
+ log?: (line: string) => void;
154
+ }): Promise<LocalScanResult>;
155
+ /** Days since the last local scan of a storefront, from history.csv; null when it never ran. */
156
+ export declare function daysSinceLocalScan(root: string, country: string, now?: Date): Promise<number | null>;
157
+ /** `ai_visibility` from the local scans in the repo: the share of answers that name the app, averaged per scan day (0–1). */
158
+ export declare function localVisibilityMetricSource(project: Project): MetricSource | null;
159
+ export {};
@@ -0,0 +1,246 @@
1
+ import { existsSync } from "node:fs";
2
+ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import YAML from "yaml";
5
+ import { indexedLocales, playLanguageFor } from "./storefronts.js";
6
+ import { languageFor, readVisibility, recordChanges, visibilityChanges, visibilityDir, visibilityPath, visibilityTarget } from "./visibility.js";
7
+ export const VISIBILITY_CORE_FILE = "./visibility-core.js";
8
+ export async function loadVisibilityCore(load = (s) => import(s)) {
9
+ try {
10
+ return (await load(VISIBILITY_CORE_FILE));
11
+ }
12
+ catch {
13
+ throw new Error("This build of @appeeky/spec has no AI visibility pipeline (dist/visibility-core.js). Update it: npm install -g @appeeky/spec@latest");
14
+ }
15
+ }
16
+ export const KEY_NAMES = "OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY or PERPLEXITY_API_KEY";
17
+ /** The listing the storefront shows, as context for intents and prompts. */
18
+ export function appContext(project, country) {
19
+ const as = project.listings["app-store"][indexedLocales(project, country)[0] ?? ""];
20
+ const playLang = playLanguageFor(project, country);
21
+ const gp = playLang ? project.listings["google-play"][playLang] : undefined;
22
+ return {
23
+ title: as?.name ?? gp?.title ?? project.spec.app.name,
24
+ subtitle: as?.subtitle ?? gp?.shortDescription,
25
+ description: as?.description ?? gp?.fullDescription,
26
+ keywords: project.spec.targets[country] ?? [],
27
+ competitors: project.spec.competitors.map((c) => c.name).filter((n) => Boolean(n)),
28
+ country,
29
+ language: languageFor(project, country),
30
+ };
31
+ }
32
+ /** Every name the assistants may use for the app. */
33
+ export function ownerNames(project) {
34
+ const names = new Set([project.spec.app.name, ...(project.spec.aiVisibility?.aliases ?? [])]);
35
+ for (const l of Object.values(project.listings["app-store"]))
36
+ if (l.name)
37
+ names.add(l.name);
38
+ for (const l of Object.values(project.listings["google-play"]))
39
+ if (l.title)
40
+ names.add(l.title);
41
+ return [...names];
42
+ }
43
+ /** The app's own domains: its website and the listing's marketing and support URLs. */
44
+ export function ownDomains(project) {
45
+ const urls = [project.spec.web?.url, ...Object.values(project.listings["app-store"]).flatMap((l) => [l.marketingUrl, l.supportUrl])];
46
+ const out = new Set();
47
+ for (const u of urls) {
48
+ if (!u)
49
+ continue;
50
+ try {
51
+ const host = new URL(u).hostname.replace(/^www\./, "");
52
+ if (!/(^|\.)(apple\.com|google\.com|notion\.site|github\.io)$/.test(host))
53
+ out.add(host);
54
+ }
55
+ catch {
56
+ // not a URL
57
+ }
58
+ }
59
+ return [...out];
60
+ }
61
+ export function planLocalScan(core, project, country, env) {
62
+ const intents = project.spec.aiVisibility?.intents[country] ?? [];
63
+ const models = core.availableModels(env, project.spec.aiVisibility?.models);
64
+ const prompts = intents.reduce((n, i) => n + i.prompts.length, 0);
65
+ return { country, intents, prompts, models, helper: core.helperFor(env), quoteUsd: core.quoteScan(prompts, models, env) };
66
+ }
67
+ async function pool(items, size, run) {
68
+ let next = 0;
69
+ await Promise.all(Array.from({ length: Math.min(size, items.length) }, async () => {
70
+ while (next < items.length)
71
+ await run(items[next++]);
72
+ }));
73
+ }
74
+ export const historyFile = (root) => join(visibilityDir(root), "history.csv");
75
+ export const answersFile = (root, country) => join(visibilityDir(root), "answers", `${country}.jsonl`);
76
+ const HISTORY_HEADER = "date,country,intent,model,visibility_pct,position,prompts\n";
77
+ const csv = (v) => (v === null || v === undefined ? "" : /[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g, '""')}"` : String(v));
78
+ /** Ask every assistant every prompt for one storefront, then write the snapshot, the answers and a history row per intent and assistant. */
79
+ export async function scanLocal(core, project, plan, env, options = {}) {
80
+ const { country } = plan;
81
+ if (!plan.helper)
82
+ throw new Error(`Set ${KEY_NAMES} to scan with your own keys.`);
83
+ if (plan.models.length === 0)
84
+ throw new Error(`None of the assistants in aiVisibility.models has a key. Set ${KEY_NAMES}.`);
85
+ const target = visibilityTarget(project);
86
+ const now = options.now ?? new Date();
87
+ const owner = ownerNames(project);
88
+ const ctx = appContext(project, country);
89
+ const jobs = plan.intents.flatMap((i) => i.prompts.map((prompt) => plan.models.map((model) => ({ intent: i.label, prompt, model })))).flat();
90
+ const scored = [];
91
+ const raw = [];
92
+ const failed = [];
93
+ let cost = 0;
94
+ let done = 0;
95
+ await pool(jobs, options.concurrency ?? 4, async (job) => {
96
+ const answer = await core.ask(job.model, core.keyFor(job.model, env), { prompt: job.prompt, country, language: ctx.language }, options.fetcher);
97
+ cost += answer.costUsd;
98
+ let mentions = [];
99
+ let status = answer.status;
100
+ if (answer.status === "ok") {
101
+ try {
102
+ const x = await core.extract(plan.helper, { answer: answer.text, prompt: job.prompt, owner: owner[0], competitors: ctx.competitors ?? [], citations: answer.citations }, options.fetcher);
103
+ cost += x.costUsd;
104
+ mentions = x.mentions;
105
+ if (x.refusal)
106
+ status = "refusal";
107
+ }
108
+ catch (e) {
109
+ status = "error";
110
+ answer.error = `extracting the apps: ${e.message}`;
111
+ }
112
+ }
113
+ if (status === "error")
114
+ failed.push({ model: job.model, error: answer.error ?? "no answer" });
115
+ const row = { ...job, status, mentions, citations: answer.citations };
116
+ scored.push(row);
117
+ raw.push({ ...row, text: answer.text, ...(answer.error ? { error: answer.error } : {}) });
118
+ done++;
119
+ if (done % 10 === 0 || done === jobs.length)
120
+ options.log?.(`${done}/${jobs.length} answers`);
121
+ });
122
+ const labels = plan.intents.map((i) => i.label);
123
+ const scores = core.score(scored, labels, owner);
124
+ const sources = core.citedDomains(scored, ownDomains(project));
125
+ const summary = { winning: 0, weak: 0, missing: 0, noData: 0 };
126
+ for (const s of scores) {
127
+ if (s.status === "no_data")
128
+ summary.noData++;
129
+ else
130
+ summary[s.status]++;
131
+ }
132
+ const order = { missing: 0, weak: 1, winning: 2, no_data: 3 };
133
+ const date = now.toISOString().slice(0, 10);
134
+ const data = {
135
+ pulledAt: date,
136
+ source: "local",
137
+ country,
138
+ store: target?.store ?? "app-store",
139
+ appId: target?.appId ?? project.spec.app.name,
140
+ summary,
141
+ intents: [...scores]
142
+ .sort((a, b) => order[a.status] - order[b.status] || (a.you ?? 0) - (b.you ?? 0))
143
+ .map((s) => {
144
+ const byModel = Object.fromEntries(Object.entries(s.byModel).map(([m, v]) => [m, Math.round(v.pct)]));
145
+ return {
146
+ label: s.label,
147
+ status: s.status,
148
+ ...(s.you !== undefined ? { you: Math.round(s.you) } : {}),
149
+ ...(Object.keys(byModel).length ? { byModel } : {}),
150
+ ...(s.namedInstead.length ? { namedInstead: s.namedInstead.map((w) => `${w.name} (${Math.round(w.pct)}%)`) } : {}),
151
+ };
152
+ }),
153
+ sources: sources.slice(0, 15).map((s) => ({ domain: s.domain, count: s.count, ...(s.yours ? { yours: true } : {}), url: s.url })),
154
+ fixes: [],
155
+ };
156
+ await mkdir(join(visibilityDir(project.root), "answers"), { recursive: true });
157
+ const file = visibilityPath(project.root, country);
158
+ const prev = await readVisibility(project.root, country);
159
+ const changes = prev ? visibilityChanges(prev, data) : [];
160
+ await recordChanges(project.root, date, country, changes);
161
+ await writeFile(file, YAML.stringify(data, { lineWidth: 0 }));
162
+ const byPrompt = (a) => `${a.intent}\u0000${a.prompt}\u0000${a.model}`;
163
+ raw.sort((a, b) => byPrompt(a).localeCompare(byPrompt(b)));
164
+ await writeFile(answersFile(project.root, country), raw.map((r) => JSON.stringify({ date, ...r })).join("\n") + "\n");
165
+ const hist = historyFile(project.root);
166
+ if (!existsSync(hist))
167
+ await writeFile(hist, HISTORY_HEADER);
168
+ const rows = scores.flatMap((s) => Object.entries(s.byModel).map(([m, v]) => [date, country, s.label, m, v.pct, v.position, v.prompts].map(csv).join(",")));
169
+ if (rows.length)
170
+ await appendFile(hist, rows.join("\n") + "\n");
171
+ return { country, file, data, changes, costUsd: Math.round(cost * 100) / 100, answered: scored.filter((a) => a.status !== "error").length, failed };
172
+ }
173
+ /** Days since the last local scan of a storefront, from history.csv; null when it never ran. */
174
+ export async function daysSinceLocalScan(root, country, now = new Date()) {
175
+ const rows = await readHistory(root);
176
+ const last = rows.filter((r) => r.country === country).map((r) => r.date).sort().at(-1);
177
+ return last ? Math.floor((now.getTime() - Date.parse(last)) / 86_400_000) : null;
178
+ }
179
+ function splitCsv(line) {
180
+ const out = [];
181
+ let cur = "";
182
+ let quoted = false;
183
+ for (let i = 0; i < line.length; i++) {
184
+ const ch = line[i];
185
+ if (quoted) {
186
+ if (ch === '"' && line[i + 1] === '"') {
187
+ cur += '"';
188
+ i++;
189
+ }
190
+ else if (ch === '"')
191
+ quoted = false;
192
+ else
193
+ cur += ch;
194
+ }
195
+ else if (ch === '"')
196
+ quoted = true;
197
+ else if (ch === ",") {
198
+ out.push(cur);
199
+ cur = "";
200
+ }
201
+ else
202
+ cur += ch;
203
+ }
204
+ out.push(cur);
205
+ return out;
206
+ }
207
+ async function readHistory(root) {
208
+ const file = historyFile(root);
209
+ if (!existsSync(file))
210
+ return [];
211
+ return (await readFile(file, "utf8"))
212
+ .split("\n")
213
+ .slice(1)
214
+ .filter(Boolean)
215
+ .map((l) => {
216
+ const [date, country, intent, model, pct] = splitCsv(l);
217
+ return { date: date, country: country, intent: intent, model: model, pct: Number(pct) };
218
+ });
219
+ }
220
+ /** `ai_visibility` from the local scans in the repo: the share of answers that name the app, averaged per scan day (0–1). */
221
+ export function localVisibilityMetricSource(project) {
222
+ const target = visibilityTarget(project);
223
+ if (!target || !existsSync(historyFile(project.root)))
224
+ return null;
225
+ const country = target.countries[0];
226
+ let history;
227
+ return {
228
+ label: "AI visibility scans in the repo",
229
+ async window(store, _appId, from, to) {
230
+ if (store !== target.store)
231
+ return null;
232
+ history ??= readHistory(project.root);
233
+ const byDay = new Map();
234
+ for (const r of await history)
235
+ if (r.country === country && r.date >= from && r.date < to && Number.isFinite(r.pct))
236
+ byDay.set(r.date, [...(byDay.get(r.date) ?? []), r.pct]);
237
+ if (byDay.size === 0)
238
+ return null;
239
+ const daily = [...byDay.values()].map((xs) => xs.reduce((a, b) => a + b, 0) / xs.length);
240
+ const days = Math.max(1, Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000));
241
+ const values = { ai_visibility: daily.reduce((a, b) => a + b, 0) / daily.length / 100 };
242
+ return { from, to, days, values };
243
+ },
244
+ };
245
+ }
246
+ //# sourceMappingURL=visibility-local.js.map