@appeeky/spec 0.1.0 → 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.
- package/README.md +158 -5
- package/agents/appeeky-ads.md +50 -0
- package/agents/appeeky-aso.md +58 -0
- package/agents/appeeky-releases.md +47 -0
- package/agents/appeeky-reviews.md +53 -0
- package/agents/appeeky-social.md +54 -0
- package/agents/appeeky-ugc.md +56 -0
- package/agents/cofounder.md +36 -0
- package/dist/ads.d.ts +5 -2
- package/dist/ads.js +8 -4
- package/dist/agents.d.ts +47 -0
- package/dist/agents.js +88 -0
- package/dist/cli.js +582 -9
- package/dist/dataforseo.d.ts +139 -0
- package/dist/dataforseo.js +354 -0
- package/dist/experiments.d.ts +4 -4
- package/dist/metrics.d.ts +20 -0
- package/dist/metrics.js +4 -0
- package/dist/report.d.ts +5 -0
- package/dist/report.js +42 -0
- package/dist/reviews.d.ts +2 -2
- package/dist/schema.d.ts +95 -2
- package/dist/schema.js +35 -0
- package/dist/search-ads.d.ts +137 -0
- package/dist/search-ads.js +354 -0
- package/dist/seo.d.ts +87 -0
- package/dist/seo.js +391 -0
- package/dist/setup.js +39 -6
- package/dist/social.d.ts +129 -0
- package/dist/social.js +189 -0
- package/dist/transport.d.ts +1 -0
- package/dist/transport.js +1 -1
- package/dist/ugc-core.js +1 -0
- package/dist/ugc-local.d.ts +116 -0
- package/dist/ugc-local.js +174 -0
- package/dist/ugc.d.ts +149 -0
- package/dist/ugc.js +111 -0
- package/dist/visibility-core.js +96 -0
- package/dist/visibility-local.d.ts +159 -0
- package/dist/visibility-local.js +246 -0
- package/dist/visibility.d.ts +3 -0
- package/dist/visibility.js +1 -1
- package/package.json +7 -3
- package/skills/appeeky-spec/SKILL.md +14 -18
- package/skills/appeeky-spec-experiments/SKILL.md +12 -2
- package/skills/appeeky-spec-visibility/SKILL.md +33 -6
package/dist/ugc.d.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { AppeekyApi } from "./appeeky-api.js";
|
|
3
|
+
/**
|
|
4
|
+
* UGC from Appeeky's creator personas, in the repo (APPEEKY_API_KEY):
|
|
5
|
+
*
|
|
6
|
+
* appeeky/ugc/personas.yaml the creator personas: who they are, their lane and platforms
|
|
7
|
+
* appeeky/ugc/clips.jsonl the clips made with them: script, caption, status, where posted
|
|
8
|
+
* appeeky/ugc/briefs/<name>.yaml a video to make: drafted, reviewed in a pull request, then generated
|
|
9
|
+
*
|
|
10
|
+
* Generating a clip uses Appeeky media credits, so it only runs when a person asks for it.
|
|
11
|
+
*/
|
|
12
|
+
export declare const ugcDir: (root: string) => string;
|
|
13
|
+
export declare const briefsDir: (root: string) => string;
|
|
14
|
+
export interface Persona {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
status?: string;
|
|
18
|
+
bio?: string;
|
|
19
|
+
lane?: string;
|
|
20
|
+
platforms?: string[];
|
|
21
|
+
contentPillars?: string[];
|
|
22
|
+
/** Clips need a look (reference images) picked in Appeeky first. */
|
|
23
|
+
ready: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface Clip {
|
|
26
|
+
id: string;
|
|
27
|
+
persona: string;
|
|
28
|
+
status: string;
|
|
29
|
+
title?: string;
|
|
30
|
+
script?: string;
|
|
31
|
+
caption?: string;
|
|
32
|
+
durationSec?: number;
|
|
33
|
+
aspectRatio?: string;
|
|
34
|
+
scheduledFor?: string;
|
|
35
|
+
socialPostId?: string;
|
|
36
|
+
error?: string;
|
|
37
|
+
createdAt: string;
|
|
38
|
+
}
|
|
39
|
+
interface PersonaRow {
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
status?: string;
|
|
43
|
+
bio?: string;
|
|
44
|
+
lane?: string;
|
|
45
|
+
platforms?: string[];
|
|
46
|
+
contentPillars?: string[];
|
|
47
|
+
referenceAssetIds?: string[];
|
|
48
|
+
}
|
|
49
|
+
interface ClipRow {
|
|
50
|
+
id: string;
|
|
51
|
+
personaId: string;
|
|
52
|
+
status: string;
|
|
53
|
+
title?: string;
|
|
54
|
+
script?: string;
|
|
55
|
+
caption?: string;
|
|
56
|
+
durationSec?: number;
|
|
57
|
+
aspectRatio?: string;
|
|
58
|
+
scheduledFor?: string | null;
|
|
59
|
+
socialPostId?: string | null;
|
|
60
|
+
error?: string | null;
|
|
61
|
+
createdAt: string;
|
|
62
|
+
}
|
|
63
|
+
export declare function toPersona(r: PersonaRow): Persona;
|
|
64
|
+
/** Signed media URLs expire within hours, so clips are stored without them. */
|
|
65
|
+
export declare function toClip(r: ClipRow, personas: Persona[]): Clip;
|
|
66
|
+
export declare function fetchUgc(api: AppeekyApi, options?: {
|
|
67
|
+
limit?: number;
|
|
68
|
+
}): Promise<{
|
|
69
|
+
personas: Persona[];
|
|
70
|
+
clips: Clip[];
|
|
71
|
+
}>;
|
|
72
|
+
export declare function saveUgc(root: string, data: {
|
|
73
|
+
personas: Persona[];
|
|
74
|
+
clips: Clip[];
|
|
75
|
+
}): Promise<void>;
|
|
76
|
+
export declare function readPersonas(root: string): Promise<Persona[]>;
|
|
77
|
+
export declare const briefSchema: z.ZodObject<{
|
|
78
|
+
/** A persona name or id from personas.yaml. */
|
|
79
|
+
persona: z.ZodString;
|
|
80
|
+
/** What the creator says, word for word. */
|
|
81
|
+
script: z.ZodString;
|
|
82
|
+
/** Where it is filmed and what is on screen. */
|
|
83
|
+
scene: z.ZodOptional<z.ZodString>;
|
|
84
|
+
caption: z.ZodOptional<z.ZodString>;
|
|
85
|
+
title: z.ZodOptional<z.ZodString>;
|
|
86
|
+
durationSec: z.ZodOptional<z.ZodNumber>;
|
|
87
|
+
aspectRatio: z.ZodOptional<z.ZodEnum<["9:16", "16:9"]>>;
|
|
88
|
+
/** The hypothesis, for whoever reviews the pull request: which hook, for whom, measured how. */
|
|
89
|
+
why: z.ZodOptional<z.ZodString>;
|
|
90
|
+
status: z.ZodDefault<z.ZodEnum<["draft", "generating", "generated"]>>;
|
|
91
|
+
clipId: z.ZodOptional<z.ZodString>;
|
|
92
|
+
credits: z.ZodOptional<z.ZodNumber>;
|
|
93
|
+
generatedAt: z.ZodOptional<z.ZodString>;
|
|
94
|
+
/** Made locally with --local: the file under .appeeky/media/, fal's URL for it and what fal charged. */
|
|
95
|
+
clipFile: z.ZodOptional<z.ZodString>;
|
|
96
|
+
videoUrl: z.ZodOptional<z.ZodString>;
|
|
97
|
+
costUsd: z.ZodOptional<z.ZodNumber>;
|
|
98
|
+
}, "strict", z.ZodTypeAny, {
|
|
99
|
+
status: "draft" | "generating" | "generated";
|
|
100
|
+
persona: string;
|
|
101
|
+
script: string;
|
|
102
|
+
title?: string | undefined;
|
|
103
|
+
why?: string | undefined;
|
|
104
|
+
scene?: string | undefined;
|
|
105
|
+
caption?: string | undefined;
|
|
106
|
+
durationSec?: number | undefined;
|
|
107
|
+
aspectRatio?: "9:16" | "16:9" | undefined;
|
|
108
|
+
clipId?: string | undefined;
|
|
109
|
+
credits?: number | undefined;
|
|
110
|
+
generatedAt?: string | undefined;
|
|
111
|
+
clipFile?: string | undefined;
|
|
112
|
+
videoUrl?: string | undefined;
|
|
113
|
+
costUsd?: number | undefined;
|
|
114
|
+
}, {
|
|
115
|
+
persona: string;
|
|
116
|
+
script: string;
|
|
117
|
+
status?: "draft" | "generating" | "generated" | undefined;
|
|
118
|
+
title?: string | undefined;
|
|
119
|
+
why?: string | undefined;
|
|
120
|
+
scene?: string | undefined;
|
|
121
|
+
caption?: string | undefined;
|
|
122
|
+
durationSec?: number | undefined;
|
|
123
|
+
aspectRatio?: "9:16" | "16:9" | undefined;
|
|
124
|
+
clipId?: string | undefined;
|
|
125
|
+
credits?: number | undefined;
|
|
126
|
+
generatedAt?: string | undefined;
|
|
127
|
+
clipFile?: string | undefined;
|
|
128
|
+
videoUrl?: string | undefined;
|
|
129
|
+
costUsd?: number | undefined;
|
|
130
|
+
}>;
|
|
131
|
+
export type Brief = z.infer<typeof briefSchema>;
|
|
132
|
+
export declare function readBriefs(root: string): Promise<Array<Brief & {
|
|
133
|
+
file: string;
|
|
134
|
+
}>>;
|
|
135
|
+
export declare function findPersona(brief: Brief, personas: Persona[]): Persona | undefined;
|
|
136
|
+
/** What the clip would cost in media credits, without making it. */
|
|
137
|
+
export declare function quoteBrief(api: AppeekyApi, brief: Brief, persona: Persona): Promise<{
|
|
138
|
+
credits: number;
|
|
139
|
+
available: number;
|
|
140
|
+
affordable: boolean;
|
|
141
|
+
}>;
|
|
142
|
+
/** Start rendering the clip (uses media credits) and record it in the brief. */
|
|
143
|
+
export declare function generateBrief(api: AppeekyApi, brief: Brief & {
|
|
144
|
+
file: string;
|
|
145
|
+
}, persona: Persona, now?: Date): Promise<{
|
|
146
|
+
clipId: string;
|
|
147
|
+
credits: number;
|
|
148
|
+
}>;
|
|
149
|
+
export {};
|
package/dist/ugc.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { SPEC_DIR, SpecError } from "./files.js";
|
|
7
|
+
/**
|
|
8
|
+
* UGC from Appeeky's creator personas, in the repo (APPEEKY_API_KEY):
|
|
9
|
+
*
|
|
10
|
+
* appeeky/ugc/personas.yaml the creator personas: who they are, their lane and platforms
|
|
11
|
+
* appeeky/ugc/clips.jsonl the clips made with them: script, caption, status, where posted
|
|
12
|
+
* appeeky/ugc/briefs/<name>.yaml a video to make: drafted, reviewed in a pull request, then generated
|
|
13
|
+
*
|
|
14
|
+
* Generating a clip uses Appeeky media credits, so it only runs when a person asks for it.
|
|
15
|
+
*/
|
|
16
|
+
export const ugcDir = (root) => join(root, SPEC_DIR, "ugc");
|
|
17
|
+
export const briefsDir = (root) => join(ugcDir(root), "briefs");
|
|
18
|
+
const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== "" && !(Array.isArray(v) && v.length === 0)));
|
|
19
|
+
export function toPersona(r) {
|
|
20
|
+
return { ...compact({ id: r.id, name: r.name, status: r.status, bio: r.bio, lane: r.lane, platforms: r.platforms, contentPillars: r.contentPillars }), ready: (r.referenceAssetIds?.length ?? 0) > 0 };
|
|
21
|
+
}
|
|
22
|
+
/** Signed media URLs expire within hours, so clips are stored without them. */
|
|
23
|
+
export function toClip(r, personas) {
|
|
24
|
+
return compact({
|
|
25
|
+
id: r.id,
|
|
26
|
+
persona: personas.find((p) => p.id === r.personaId)?.name ?? r.personaId,
|
|
27
|
+
status: r.status,
|
|
28
|
+
title: r.title,
|
|
29
|
+
script: r.script,
|
|
30
|
+
caption: r.caption,
|
|
31
|
+
durationSec: r.durationSec,
|
|
32
|
+
aspectRatio: r.aspectRatio,
|
|
33
|
+
scheduledFor: r.scheduledFor ?? undefined,
|
|
34
|
+
socialPostId: r.socialPostId ?? undefined,
|
|
35
|
+
error: r.error ?? undefined,
|
|
36
|
+
createdAt: r.createdAt,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export async function fetchUgc(api, options = {}) {
|
|
40
|
+
const personas = ((await api.get("/ugc/personas"))?.personas ?? []).map(toPersona);
|
|
41
|
+
const clips = ((await api.get("/ugc/clips", { limit: String(options.limit ?? 100) }))?.clips ?? []).map((c) => toClip(c, personas));
|
|
42
|
+
return { personas, clips };
|
|
43
|
+
}
|
|
44
|
+
export async function saveUgc(root, data) {
|
|
45
|
+
await mkdir(ugcDir(root), { recursive: true });
|
|
46
|
+
await writeFile(join(ugcDir(root), "personas.yaml"), YAML.stringify({ personas: data.personas }, { lineWidth: 0 }));
|
|
47
|
+
await writeFile(join(ugcDir(root), "clips.jsonl"), data.clips.map((c) => JSON.stringify(c)).join("\n") + (data.clips.length ? "\n" : ""));
|
|
48
|
+
}
|
|
49
|
+
export async function readPersonas(root) {
|
|
50
|
+
const file = join(ugcDir(root), "personas.yaml");
|
|
51
|
+
if (!existsSync(file))
|
|
52
|
+
return [];
|
|
53
|
+
return (YAML.parse(await readFile(file, "utf8"))?.personas ?? []);
|
|
54
|
+
}
|
|
55
|
+
// ── Briefs ──────────────────────────────────────────────────────────────────
|
|
56
|
+
export const briefSchema = z
|
|
57
|
+
.object({
|
|
58
|
+
/** A persona name or id from personas.yaml. */
|
|
59
|
+
persona: z.string().min(1),
|
|
60
|
+
/** What the creator says, word for word. */
|
|
61
|
+
script: z.string().min(1),
|
|
62
|
+
/** Where it is filmed and what is on screen. */
|
|
63
|
+
scene: z.string().optional(),
|
|
64
|
+
caption: z.string().optional(),
|
|
65
|
+
title: z.string().optional(),
|
|
66
|
+
durationSec: z.number().int().positive().optional(),
|
|
67
|
+
aspectRatio: z.enum(["9:16", "16:9"]).optional(),
|
|
68
|
+
/** The hypothesis, for whoever reviews the pull request: which hook, for whom, measured how. */
|
|
69
|
+
why: z.string().optional(),
|
|
70
|
+
status: z.enum(["draft", "generating", "generated"]).default("draft"),
|
|
71
|
+
clipId: z.string().optional(),
|
|
72
|
+
credits: z.number().optional(),
|
|
73
|
+
generatedAt: z.string().optional(),
|
|
74
|
+
/** Made locally with --local: the file under .appeeky/media/, fal's URL for it and what fal charged. */
|
|
75
|
+
clipFile: z.string().optional(),
|
|
76
|
+
videoUrl: z.string().url().optional(),
|
|
77
|
+
costUsd: z.number().optional(),
|
|
78
|
+
})
|
|
79
|
+
.strict();
|
|
80
|
+
export async function readBriefs(root) {
|
|
81
|
+
const dir = briefsDir(root);
|
|
82
|
+
if (!existsSync(dir))
|
|
83
|
+
return [];
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const f of (await readdir(dir)).filter((f) => f.endsWith(".yaml")).sort()) {
|
|
86
|
+
const parsed = briefSchema.safeParse(YAML.parse(await readFile(join(dir, f), "utf8")));
|
|
87
|
+
if (!parsed.success)
|
|
88
|
+
throw new SpecError(parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "), join(dir, f));
|
|
89
|
+
out.push({ ...parsed.data, file: join(dir, f) });
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
export function findPersona(brief, personas) {
|
|
94
|
+
const ref = brief.persona.toLowerCase();
|
|
95
|
+
return personas.find((p) => p.id === brief.persona || p.name.toLowerCase() === ref);
|
|
96
|
+
}
|
|
97
|
+
function clipBody(brief) {
|
|
98
|
+
return compact({ script: brief.script, scene: brief.scene, caption: brief.caption, title: brief.title, durationSec: brief.durationSec, aspectRatio: brief.aspectRatio });
|
|
99
|
+
}
|
|
100
|
+
/** What the clip would cost in media credits, without making it. */
|
|
101
|
+
export async function quoteBrief(api, brief, persona) {
|
|
102
|
+
return api.post(`/ugc/personas/${encodeURIComponent(persona.id)}/clips`, { ...clipBody(brief), dryRun: true });
|
|
103
|
+
}
|
|
104
|
+
/** Start rendering the clip (uses media credits) and record it in the brief. */
|
|
105
|
+
export async function generateBrief(api, brief, persona, now = new Date()) {
|
|
106
|
+
const res = await api.post(`/ugc/personas/${encodeURIComponent(persona.id)}/clips`, clipBody(brief));
|
|
107
|
+
const { file, ...rest } = brief;
|
|
108
|
+
await writeFile(file, YAML.stringify({ ...rest, status: "generating", clipId: res.clip.id, credits: res.credits, generatedAt: now.toISOString() }, { lineWidth: 0 }));
|
|
109
|
+
return { clipId: res.clip.id, credits: res.credits };
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=ugc.js.map
|
|
@@ -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 {};
|