@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/cli.js
CHANGED
|
@@ -5,10 +5,18 @@ import { join, resolve } from "node:path";
|
|
|
5
5
|
import { createInterface } from "node:readline/promises";
|
|
6
6
|
import { Command, Option } from "commander";
|
|
7
7
|
import { adsSnapshot, CHANNEL_LABELS, saveAdsSnapshot } from "./ads.js";
|
|
8
|
+
import { COFOUNDER_COMMAND, installAgents, parseTargets, TARGET_DIRS } from "./agents.js";
|
|
8
9
|
import { attributionClient, attributionClients, attributionMetricSource, createExperimentLink, hasClients, IOS_LINK_NOTE, iosOnlyLink, isClient, linkResult, linkStore } from "./attribution.js";
|
|
9
10
|
import { pullCompetitors, similarApps } from "./competitors.js";
|
|
11
|
+
import { draftIssues, fetchSocial, readAccounts, readDrafts, resolveAccounts, saveSocial, sendDraft } from "./social.js";
|
|
12
|
+
import { fetchUgc, findPersona, generateBrief, quoteBrief, readBriefs, readPersonas, saveUgc } from "./ugc.js";
|
|
13
|
+
import { generateLocal, loadCore, planLocalClip, readAppeekyPersona, readLocalPersona } from "./ugc-local.js";
|
|
10
14
|
import { categoryFile, categoryMetricSource, categoryPlans, chartSources, previousRank, readCategoryHistory, readCategoryRanks, saveCategoryRanks } from "./category.js";
|
|
11
15
|
import { applySync, bootstrapIntents, languageFor, planSync, pullVisibility, readRemoteIntents, scanVisibility, visibilityMetricSource, visibilityTarget } from "./visibility.js";
|
|
16
|
+
import { appContext, daysSinceLocalScan, KEY_NAMES, loadVisibilityCore, localVisibilityMetricSource, planLocalScan, scanLocal } from "./visibility-local.js";
|
|
17
|
+
import { pullSearchAds, SearchAdsClient, searchAdsMetricSource, searchAdsSource } from "./search-ads.js";
|
|
18
|
+
import { daysSinceRanks, pullDomain, pullKeywords, pullRanks, quoteSeo, readDataForSeo, webKeywords, webRankMetricSource } from "./dataforseo.js";
|
|
19
|
+
import { checkSite, llmsTxt, pullSearchConsole, readServiceAccount, searchConsoleProperty, seoMetricSource } from "./seo.js";
|
|
12
20
|
import { attributionBlock, initHosted, initSelfHosted, nextSteps, specStores } from "./attribution-setup.js";
|
|
13
21
|
import { pullData } from "./data.js";
|
|
14
22
|
import { combineSources } from "./metrics.js";
|
|
@@ -17,7 +25,7 @@ import { AppeekyApi, appeekyRankSource, loadPopularity, metricSourceFor } from "
|
|
|
17
25
|
import { closeExperiment, dueExperiments, experimentId, readExperiments, writeExperiment } from "./experiments.js";
|
|
18
26
|
import { appendPlaybook, experimentApp, measureExperiments, playbookLine, verifyReleases } from "./measure.js";
|
|
19
27
|
import { formatChange, isMetricName, METRICS, metricAfter, metricBefore } from "./metrics.js";
|
|
20
|
-
import { loadProjectAtRef, specReport } from "./report.js";
|
|
28
|
+
import { loadProjectAtRef, outboxReport, specReport } from "./report.js";
|
|
21
29
|
import { checkScreenshots, pullScreenshots } from "./screenshots.js";
|
|
22
30
|
import { appendKeyword, crossLocalizationSuggestions, keywordSuggestions } from "./suggest.js";
|
|
23
31
|
import { firstRankSource, itunesRankSource, rankAppIds, snapshotRanks } from "./verify.js";
|
|
@@ -283,6 +291,10 @@ export function buildProgram() {
|
|
|
283
291
|
if (result.status !== 0)
|
|
284
292
|
console.log(c.yellow(" The skills did not install. Run `npx skills add appeeky/appeeky-spec` later."));
|
|
285
293
|
}
|
|
294
|
+
if (await agree(`Add the specialist agents and the /${COFOUNDER_COMMAND} review for Claude Code?`)) {
|
|
295
|
+
printInstalled(await installAgents(root, ["claude"]));
|
|
296
|
+
console.log(c.dim(` In Claude Code, run /${COFOUNDER_COMMAND} for a growth review. Other agents: \`appeeky-spec agents install --for opencode\`.`));
|
|
297
|
+
}
|
|
286
298
|
step(6, "Where you stand");
|
|
287
299
|
if (pulled && targets.length)
|
|
288
300
|
await program.parseAsync(["coverage", "--country", country], { from: "user" });
|
|
@@ -330,6 +342,27 @@ export function buildProgram() {
|
|
|
330
342
|
console.log(` ${c.dim(x.fix)}`);
|
|
331
343
|
}
|
|
332
344
|
}
|
|
345
|
+
function printInstalled(files) {
|
|
346
|
+
const mark = { written: c.green("✓"), unchanged: c.dim("="), kept: c.yellow("!") };
|
|
347
|
+
for (const f of files)
|
|
348
|
+
console.log(` ${mark[f.status]} ${f.path}${f.status === "kept" ? c.dim(" (yours differs: kept, --force to replace)") : ""}`);
|
|
349
|
+
}
|
|
350
|
+
const agents = program.command("agents").description("Specialist subagents (ASO, reviews, ads, releases, UGC, social) and the co-founder review, for your coding agent");
|
|
351
|
+
agents
|
|
352
|
+
.command("install")
|
|
353
|
+
.description(`Write the specialists and the /${COFOUNDER_COMMAND} command into this repo (${Object.values(TARGET_DIRS).map((d) => d.agents).join(", ")})`)
|
|
354
|
+
.option("--for <targets>", "claude, opencode or all, comma separated", "claude")
|
|
355
|
+
.option("--force", "Replace files you changed", false)
|
|
356
|
+
.action(async (opts) => {
|
|
357
|
+
const root = resolve(g().cwd);
|
|
358
|
+
const files = await installAgents(root, parseTargets(opts.for), { force: opts.force });
|
|
359
|
+
if (g().json) {
|
|
360
|
+
printJson({ root, files });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
printInstalled(files);
|
|
364
|
+
console.log(c.dim(`Commit them. Then run /${COFOUNDER_COMMAND} in your agent, or schedule it (examples/weekly/).`));
|
|
365
|
+
});
|
|
333
366
|
program
|
|
334
367
|
.command("doctor")
|
|
335
368
|
.description("What is set up for this repo, what is missing, and how to fix it")
|
|
@@ -579,15 +612,16 @@ export function buildProgram() {
|
|
|
579
612
|
});
|
|
580
613
|
program
|
|
581
614
|
.command("ads")
|
|
582
|
-
.description("Spend and installs of your paid channels: Apple Search Ads, plus the Meta and TikTok campaigns under ads: (needs APPEEKY_API_KEY)")
|
|
615
|
+
.description("Spend and installs of your paid channels: Apple Search Ads (from Appeeky, or appeeky/search-ads/ pulled with your own key), plus the Meta and TikTok campaigns under ads: (needs APPEEKY_API_KEY)")
|
|
583
616
|
.option("--days <n>", "How many days, up to yesterday", "30")
|
|
584
617
|
.option("--save", "Write the numbers to appeeky/ads/<date>.yaml", false)
|
|
585
618
|
.action(async (opts) => {
|
|
586
619
|
const project = await load();
|
|
587
620
|
const a = api();
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
621
|
+
const own = searchAdsMetricSource(project.root);
|
|
622
|
+
if (!a && !own)
|
|
623
|
+
throw new Error("Set APPEEKY_API_KEY (ad data from the channels you connected in Appeeky), or pull Apple Search Ads with your own key: appeeky-spec search-ads pull.");
|
|
624
|
+
const snapshot = await adsSnapshot(a ?? null, project.spec, experimentApp(project, {}), Number(opts.days) || 30, new Date(), own);
|
|
591
625
|
const saved = opts.save ? await saveAdsSnapshot(project.root, snapshot) : undefined;
|
|
592
626
|
if (g().json) {
|
|
593
627
|
printJson({ ...snapshot, ...(saved ? { saved } : {}) });
|
|
@@ -741,6 +775,222 @@ export function buildProgram() {
|
|
|
741
775
|
for (const f of failed)
|
|
742
776
|
console.log(`${c.red(f.id)} ${c.dim(f.message)}`);
|
|
743
777
|
});
|
|
778
|
+
const social = program.command("social").description("Organic social media in the repo: accounts, posts and their results, and posts sent through a pull request (needs APPEEKY_API_KEY)");
|
|
779
|
+
social
|
|
780
|
+
.command("pull")
|
|
781
|
+
.description("Write the connected accounts, recent posts and their analytics to appeeky/social/, and summary.md")
|
|
782
|
+
.option("--limit <n>", "How many recent posts", "100")
|
|
783
|
+
.action(async (opts) => {
|
|
784
|
+
const project = await load();
|
|
785
|
+
const a = api();
|
|
786
|
+
if (!a)
|
|
787
|
+
throw new Error("Set APPEEKY_API_KEY: posts come from the social accounts connected in Appeeky.");
|
|
788
|
+
const data = await fetchSocial(a, { limit: Number(opts.limit) || 100 });
|
|
789
|
+
await saveSocial(project.root, data);
|
|
790
|
+
if (g().json) {
|
|
791
|
+
printJson({ accounts: data.accounts.length, posts: data.posts.length });
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
console.log(`${c.bold("Accounts")} ${data.accounts.map((x) => `${x.platform} (${x.name})`).join(", ") || c.dim("none connected")}`);
|
|
795
|
+
console.log(`${c.bold("Posts")} ${data.posts.length} · ${data.posts.filter((p) => p.platforms.some((x) => x.publishedAt)).length} published`);
|
|
796
|
+
console.log(c.dim("Written to appeeky/social/: accounts.yaml, posts.jsonl, summary.md."));
|
|
797
|
+
});
|
|
798
|
+
social
|
|
799
|
+
.command("check")
|
|
800
|
+
.description("Check the drafts in appeeky/social/drafts/: connected accounts, length, media, time")
|
|
801
|
+
.action(async () => {
|
|
802
|
+
const project = await load();
|
|
803
|
+
const accounts = await readAccounts(project.root);
|
|
804
|
+
const drafts = (await readDrafts(project.root)).filter((d) => d.status === "draft");
|
|
805
|
+
const results = drafts.map((d) => ({ file: d.file.replace(project.root + "/", ""), issues: draftIssues(d, accounts) }));
|
|
806
|
+
if (g().json)
|
|
807
|
+
printJson({ drafts: results });
|
|
808
|
+
else if (!results.length)
|
|
809
|
+
console.log(c.dim("No drafts in appeeky/social/drafts/."));
|
|
810
|
+
else
|
|
811
|
+
for (const r of results)
|
|
812
|
+
console.log(`${r.issues.length ? c.red("✗") : c.green("✓")} ${r.file}${r.issues.length ? `\n ${c.dim(r.issues.join("; "))}` : ""}`);
|
|
813
|
+
if (results.some((r) => r.issues.length))
|
|
814
|
+
process.exitCode = 1;
|
|
815
|
+
});
|
|
816
|
+
social
|
|
817
|
+
.command("push")
|
|
818
|
+
.description("Send the drafts in appeeky/social/drafts/: each goes out now, or at its `when`")
|
|
819
|
+
.option("--yes", "Send without asking", false)
|
|
820
|
+
.action(async (opts) => {
|
|
821
|
+
const project = await load();
|
|
822
|
+
const a = api();
|
|
823
|
+
if (!a)
|
|
824
|
+
throw new Error("Set APPEEKY_API_KEY: posts go out through the social accounts connected in Appeeky.");
|
|
825
|
+
const accounts = await readAccounts(project.root);
|
|
826
|
+
const drafts = (await readDrafts(project.root)).filter((d) => d.status === "draft");
|
|
827
|
+
const blocked = drafts.filter((d) => draftIssues(d, accounts).length);
|
|
828
|
+
if (blocked.length)
|
|
829
|
+
throw new Error(`Fix first (appeeky-spec social check): ${blocked.map((d) => `${d.file.replace(project.root + "/", "")} (${draftIssues(d, accounts).join("; ")})`).join("; ")}`);
|
|
830
|
+
if (drafts.length === 0) {
|
|
831
|
+
if (g().json)
|
|
832
|
+
printJson({ sent: [] });
|
|
833
|
+
else
|
|
834
|
+
console.log(c.dim("No drafts to send."));
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (!g().json)
|
|
838
|
+
for (const d of drafts) {
|
|
839
|
+
const to = resolveAccounts(d, accounts).accounts.map((x) => `${x.platform} (${x.name})`).join(", ");
|
|
840
|
+
console.log(`${c.bold(d.file.replace(project.root + "/", ""))} ${c.dim(`→ ${to}, ${d.when ? `at ${d.when}` : "now"}`)}\n ${d.text}`);
|
|
841
|
+
}
|
|
842
|
+
if (!opts.yes && !(await confirm(`Publish ${drafts.length} post${drafts.length === 1 ? "" : "s"}? They go out on the accounts above.`)))
|
|
843
|
+
return;
|
|
844
|
+
const sent = [];
|
|
845
|
+
const failed = [];
|
|
846
|
+
for (const d of drafts) {
|
|
847
|
+
const file = d.file.replace(project.root + "/", "");
|
|
848
|
+
try {
|
|
849
|
+
sent.push({ file, postId: await sendDraft(a, d, accounts) });
|
|
850
|
+
}
|
|
851
|
+
catch (e) {
|
|
852
|
+
failed.push({ file, message: e.message });
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (g().json)
|
|
856
|
+
printJson({ sent, failed });
|
|
857
|
+
else {
|
|
858
|
+
console.log(c.green(`Sent ${sent.length}.`));
|
|
859
|
+
for (const f of failed)
|
|
860
|
+
console.log(`${c.red(f.file)} ${c.dim(f.message)}`);
|
|
861
|
+
}
|
|
862
|
+
if (failed.length)
|
|
863
|
+
process.exitCode = 1;
|
|
864
|
+
});
|
|
865
|
+
const ugc = program.command("ugc").description("UGC from your Appeeky creator personas: clips in the repo, and videos made from briefs (needs APPEEKY_API_KEY)");
|
|
866
|
+
ugc
|
|
867
|
+
.command("pull")
|
|
868
|
+
.description("Write your creator personas and their clips to appeeky/ugc/")
|
|
869
|
+
.option("--limit <n>", "How many recent clips", "100")
|
|
870
|
+
.action(async (opts) => {
|
|
871
|
+
const project = await load();
|
|
872
|
+
const a = api();
|
|
873
|
+
if (!a)
|
|
874
|
+
throw new Error("Set APPEEKY_API_KEY: personas and clips come from Appeeky UGC.");
|
|
875
|
+
const data = await fetchUgc(a, { limit: Number(opts.limit) || 100 });
|
|
876
|
+
await saveUgc(project.root, data);
|
|
877
|
+
if (g().json) {
|
|
878
|
+
printJson({ personas: data.personas.length, clips: data.clips.length });
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
console.log(`${c.bold("Personas")} ${data.personas.map((p) => `${p.name}${p.ready ? "" : c.dim(" (no look yet)")}`).join(", ") || c.dim("none yet: create one in Appeeky (UGC)")}`);
|
|
882
|
+
console.log(`${c.bold("Clips")} ${data.clips.length}`);
|
|
883
|
+
console.log(c.dim("Written to appeeky/ugc/: personas.yaml, clips.jsonl."));
|
|
884
|
+
});
|
|
885
|
+
ugc
|
|
886
|
+
.command("generate")
|
|
887
|
+
.description("Make a clip from each draft brief in appeeky/ugc/briefs/ (uses Appeeky media credits, or your own fal key with --local)")
|
|
888
|
+
.option("--dry-run", "Only show what each clip would cost", false)
|
|
889
|
+
.option("--yes", "Generate without asking", false)
|
|
890
|
+
.option("--local", "Render here with your own FAL_KEY (fal bills you; no Appeeky credits)", false)
|
|
891
|
+
.action(async (opts) => {
|
|
892
|
+
const project = await load();
|
|
893
|
+
if (opts.local)
|
|
894
|
+
return generateLocalClips(project.root, opts);
|
|
895
|
+
const a = api();
|
|
896
|
+
if (!a)
|
|
897
|
+
throw new Error("Set APPEEKY_API_KEY: clips are made with Appeeky UGC.");
|
|
898
|
+
const personas = await readPersonas(project.root);
|
|
899
|
+
const briefs = (await readBriefs(project.root)).filter((b) => b.status === "draft");
|
|
900
|
+
const unknown = briefs.filter((b) => !findPersona(b, personas));
|
|
901
|
+
if (unknown.length)
|
|
902
|
+
throw new Error(`Unknown persona (run \`appeeky-spec ugc pull\`): ${unknown.map((b) => `${b.persona} in ${b.file.replace(project.root + "/", "")}`).join("; ")}`);
|
|
903
|
+
const notReady = briefs.filter((b) => findPersona(b, personas).ready === false);
|
|
904
|
+
if (notReady.length)
|
|
905
|
+
throw new Error(`Pick a look (reference images) in Appeeky UGC first for: ${[...new Set(notReady.map((b) => b.persona))].join(", ")}`);
|
|
906
|
+
if (briefs.length === 0) {
|
|
907
|
+
if (g().json)
|
|
908
|
+
printJson({ briefs: [] });
|
|
909
|
+
else
|
|
910
|
+
console.log(c.dim("No draft briefs in appeeky/ugc/briefs/."));
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const quotes = [];
|
|
914
|
+
for (const b of briefs)
|
|
915
|
+
quotes.push({ file: b.file.replace(project.root + "/", ""), persona: b.persona, ...(await quoteBrief(a, b, findPersona(b, personas))) });
|
|
916
|
+
const credits = quotes.reduce((n, q) => n + q.credits, 0);
|
|
917
|
+
if (!g().json)
|
|
918
|
+
for (const q of quotes)
|
|
919
|
+
console.log(`${c.bold(q.file)} ${c.dim(`${q.persona} · ${q.credits} credits${q.affordable ? "" : " · not enough credits"}`)}`);
|
|
920
|
+
if (opts.dryRun) {
|
|
921
|
+
if (g().json)
|
|
922
|
+
printJson({ dryRun: true, briefs: quotes, credits });
|
|
923
|
+
else
|
|
924
|
+
console.log(c.dim(`\n${credits} credits in all. Nothing was made.`));
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
if (quotes.some((q) => !q.affordable))
|
|
928
|
+
throw new Error("Not enough media credits for every brief. Top up in Appeeky, or remove a brief.");
|
|
929
|
+
if (!opts.yes && !(await confirm(`Make ${briefs.length} clip${briefs.length === 1 ? "" : "s"} for ${credits} credits?`)))
|
|
930
|
+
return;
|
|
931
|
+
const made = [];
|
|
932
|
+
for (const b of briefs)
|
|
933
|
+
made.push({ file: b.file.replace(project.root + "/", ""), ...(await generateBrief(a, b, findPersona(b, personas))) });
|
|
934
|
+
if (g().json)
|
|
935
|
+
printJson({ made, credits });
|
|
936
|
+
else
|
|
937
|
+
console.log(c.green(`Started ${made.length}. They render in Appeeky; \`appeeky-spec ugc pull\` records them when done.`));
|
|
938
|
+
});
|
|
939
|
+
async function generateLocalClips(root, opts) {
|
|
940
|
+
const falKey = process.env.FAL_KEY?.trim();
|
|
941
|
+
if (!falKey && !opts.dryRun)
|
|
942
|
+
throw new Error("Set FAL_KEY (fal.ai → API keys): local clips are billed to your fal account.");
|
|
943
|
+
const briefs = (await readBriefs(root)).filter((b) => b.status === "draft");
|
|
944
|
+
if (briefs.length === 0) {
|
|
945
|
+
if (g().json)
|
|
946
|
+
printJson({ briefs: [] });
|
|
947
|
+
else
|
|
948
|
+
console.log(c.dim("No draft briefs in appeeky/ugc/briefs/."));
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const core = await loadCore();
|
|
952
|
+
const a = api();
|
|
953
|
+
const personas = new Map();
|
|
954
|
+
for (const b of briefs) {
|
|
955
|
+
if (personas.has(b.persona))
|
|
956
|
+
continue;
|
|
957
|
+
const p = (await readLocalPersona(root, b.persona)) ?? (a ? await readAppeekyPersona(a, b.persona) : undefined);
|
|
958
|
+
if (!p)
|
|
959
|
+
throw new Error(`Unknown persona ${b.persona}: add appeeky/ugc/personas/${b.persona.toLowerCase()}.yaml with its looks${a ? "" : ", or set APPEEKY_API_KEY to use one from Appeeky"}.`);
|
|
960
|
+
personas.set(b.persona, p);
|
|
961
|
+
}
|
|
962
|
+
const rel = (f) => f.replace(root + "/", "");
|
|
963
|
+
const quotes = briefs.map((b) => {
|
|
964
|
+
const plan = planLocalClip(core, personas.get(b.persona), b, []);
|
|
965
|
+
return { file: rel(b.file), persona: b.persona, ...plan.clip, costUsd: plan.costUsd };
|
|
966
|
+
});
|
|
967
|
+
const total = Math.round(quotes.reduce((n, q) => n + q.costUsd, 0) * 100) / 100;
|
|
968
|
+
if (!g().json)
|
|
969
|
+
for (const q of quotes)
|
|
970
|
+
console.log(`${c.bold(q.file)} ${c.dim(`${q.persona} · ${q.durationSec}s ${q.aspectRatio} · ${q.videoModelId} · about $${q.costUsd.toFixed(2)} on fal`)}`);
|
|
971
|
+
if (opts.dryRun) {
|
|
972
|
+
if (g().json)
|
|
973
|
+
printJson({ dryRun: true, local: true, briefs: quotes, costUsd: total });
|
|
974
|
+
else
|
|
975
|
+
console.log(c.dim(`\nAbout $${total.toFixed(2)} on your fal account in all. Nothing was made.`));
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
if (!opts.yes && !(await confirm(`Make ${briefs.length} clip${briefs.length === 1 ? "" : "s"} on fal for about $${total.toFixed(2)}?`)))
|
|
979
|
+
return;
|
|
980
|
+
const made = [];
|
|
981
|
+
for (const b of briefs) {
|
|
982
|
+
if (!g().json)
|
|
983
|
+
console.log(c.bold(rel(b.file)));
|
|
984
|
+
const r = await generateLocal(core, root, b, personas.get(b.persona), falKey, { log: (l) => !g().json && console.log(c.dim(` ${l}`)) });
|
|
985
|
+
made.push({ file: rel(b.file), clip: rel(r.file), videoUrl: r.videoUrl, costUsd: r.costUsd });
|
|
986
|
+
if (!g().json)
|
|
987
|
+
console.log(` ${c.green("✓")} ${rel(r.file)}`);
|
|
988
|
+
}
|
|
989
|
+
if (g().json)
|
|
990
|
+
printJson({ made, costUsd: total });
|
|
991
|
+
else
|
|
992
|
+
console.log(c.dim("\nThe clips are in .appeeky/media/ (gitignored); each brief has its videoUrl for a social draft's media:."));
|
|
993
|
+
}
|
|
744
994
|
const competitors = program.command("competitors").description("Competitors in the repo: their listing, ranks and reviews (needs APPEEKY_API_KEY)");
|
|
745
995
|
competitors
|
|
746
996
|
.command("pull")
|
|
@@ -802,7 +1052,7 @@ export function buildProgram() {
|
|
|
802
1052
|
});
|
|
803
1053
|
const visibility = program
|
|
804
1054
|
.command("visibility")
|
|
805
|
-
.description("Whether ChatGPT, Claude, Gemini and Perplexity name your app
|
|
1055
|
+
.description("Whether ChatGPT, Claude, Gemini and Perplexity name your app: from Appeeky AI Visibility (APPEEKY_API_KEY), or with --local on your own model keys");
|
|
806
1056
|
const needKey = () => {
|
|
807
1057
|
const a = api();
|
|
808
1058
|
if (!a)
|
|
@@ -819,7 +1069,10 @@ export function buildProgram() {
|
|
|
819
1069
|
.command("init")
|
|
820
1070
|
.description("Write the intents and prompts Appeeky tracks into appeeky.spec.yaml; with none yet, have Appeeky write them from your listing")
|
|
821
1071
|
.option("--country <cc>", "Storefront (default: aiVisibility.countries, or the first under targets)")
|
|
1072
|
+
.option("--local", `Write them with your own model key (${KEY_NAMES}) instead of Appeeky`, false)
|
|
822
1073
|
.action(async (opts) => {
|
|
1074
|
+
if (opts.local)
|
|
1075
|
+
return initLocal(opts.country);
|
|
823
1076
|
const project = await load();
|
|
824
1077
|
const a = needKey();
|
|
825
1078
|
const t = watched(project);
|
|
@@ -935,9 +1188,15 @@ export function buildProgram() {
|
|
|
935
1188
|
});
|
|
936
1189
|
visibility
|
|
937
1190
|
.command("scan")
|
|
938
|
-
.description("Ask the assistants now
|
|
1191
|
+
.description("Ask the assistants now: in Appeeky (AI Visibility credits), or with --local on your own model keys")
|
|
939
1192
|
.option("--country <cc>", "One storefront only")
|
|
1193
|
+
.option("--local", `Ask them from here with your own keys (${KEY_NAMES}) and write appeeky/visibility/`, false)
|
|
1194
|
+
.option("--every <days>", "With --local: skip a storefront scanned in the last <days> days (for a daily schedule)")
|
|
1195
|
+
.option("--dry-run", "With --local: show the prompts, assistants and cost, and ask nothing", false)
|
|
1196
|
+
.option("-y, --yes", "With --local: do not ask before spending", false)
|
|
940
1197
|
.action(async (opts) => {
|
|
1198
|
+
if (opts.local)
|
|
1199
|
+
return scanLocalCmd(opts);
|
|
941
1200
|
const project = await load();
|
|
942
1201
|
const a = needKey();
|
|
943
1202
|
const t = watched(project);
|
|
@@ -950,6 +1209,312 @@ export function buildProgram() {
|
|
|
950
1209
|
}
|
|
951
1210
|
console.log(c.green(`Scan started for ${countries.map((x) => x.toUpperCase()).join(", ")}.`) + c.dim(" Pull the results in a few minutes: appeeky-spec visibility pull"));
|
|
952
1211
|
});
|
|
1212
|
+
async function initLocal(country) {
|
|
1213
|
+
const project = await load();
|
|
1214
|
+
const core = await loadVisibilityCore();
|
|
1215
|
+
const helper = core.helperFor(process.env);
|
|
1216
|
+
if (!helper)
|
|
1217
|
+
throw new Error(`Set ${KEY_NAMES}: the intents and prompts are written by your own model.`);
|
|
1218
|
+
const t = visibilityTarget(project);
|
|
1219
|
+
const countries = country ? [country.toLowerCase()] : (t?.countries ?? [Object.keys(project.spec.targets)[0] ?? "us"]);
|
|
1220
|
+
const spec = project.spec;
|
|
1221
|
+
spec.aiVisibility ??= { intents: {} };
|
|
1222
|
+
let cost = 0;
|
|
1223
|
+
const report = [];
|
|
1224
|
+
for (const cc of countries) {
|
|
1225
|
+
const have = spec.aiVisibility.intents[cc] ?? [];
|
|
1226
|
+
if (have.length) {
|
|
1227
|
+
report.push({ country: cc, intents: have.length, skipped: true });
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
const r = await core.generateIntents(helper, appContext(project, cc));
|
|
1231
|
+
cost += r.costUsd;
|
|
1232
|
+
spec.aiVisibility.intents[cc] = r.intents;
|
|
1233
|
+
report.push({ country: cc, intents: r.intents.length });
|
|
1234
|
+
}
|
|
1235
|
+
if (country && t && !t.countries.includes(countries[0]))
|
|
1236
|
+
spec.aiVisibility.countries = [...t.countries, countries[0]];
|
|
1237
|
+
if (report.some((r) => !r.skipped && r.intents))
|
|
1238
|
+
await writeSpec(project.root, spec);
|
|
1239
|
+
if (g().json) {
|
|
1240
|
+
printJson({ model: helper.modelId, costUsd: cost, report });
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1243
|
+
for (const r of report) {
|
|
1244
|
+
if (r.skipped)
|
|
1245
|
+
console.log(c.dim(`${r.country.toUpperCase()}: already has ${r.intents} intents in appeeky.spec.yaml; edit them there.`));
|
|
1246
|
+
else
|
|
1247
|
+
console.log(`${c.green("✓")} ${r.country.toUpperCase()}: wrote ${r.intents} intents to aiVisibility.intents in appeeky.spec.yaml`);
|
|
1248
|
+
}
|
|
1249
|
+
console.log(c.dim(`\n${helper.modelId} on your key, about $${cost.toFixed(3)}. Edit them, then: appeeky-spec visibility scan --local --dry-run`));
|
|
1250
|
+
}
|
|
1251
|
+
async function scanLocalCmd(opts) {
|
|
1252
|
+
const project = await load();
|
|
1253
|
+
const core = await loadVisibilityCore();
|
|
1254
|
+
const t = visibilityTarget(project);
|
|
1255
|
+
const all = Object.keys(project.spec.aiVisibility?.intents ?? {});
|
|
1256
|
+
const countries = opts.country ? [opts.country.toLowerCase()] : (t?.countries ?? all).filter((cc) => all.includes(cc));
|
|
1257
|
+
if (countries.length === 0)
|
|
1258
|
+
throw new Error("No aiVisibility.intents in appeeky.spec.yaml. Write them with appeeky-spec visibility init --local.");
|
|
1259
|
+
const every = opts.every ? Number(opts.every) : 0;
|
|
1260
|
+
const plans = [];
|
|
1261
|
+
for (const cc of countries) {
|
|
1262
|
+
const since = every ? await daysSinceLocalScan(project.root, cc) : null;
|
|
1263
|
+
if (since !== null && since < every) {
|
|
1264
|
+
if (!g().json)
|
|
1265
|
+
console.log(c.dim(`${cc.toUpperCase()}: scanned ${since} day${since === 1 ? "" : "s"} ago, next in ${every - since}.`));
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
plans.push(planLocalScan(core, project, cc, process.env));
|
|
1269
|
+
}
|
|
1270
|
+
if (plans.length === 0)
|
|
1271
|
+
return;
|
|
1272
|
+
if (!plans[0].helper || plans[0].models.length === 0)
|
|
1273
|
+
throw new Error(`Set ${KEY_NAMES} to scan with your own keys. Each key adds that assistant.`);
|
|
1274
|
+
const total = plans.reduce((n, p) => n + p.quoteUsd, 0);
|
|
1275
|
+
if (!g().json || opts.dryRun) {
|
|
1276
|
+
for (const p of plans)
|
|
1277
|
+
console.log(`${p.country.toUpperCase()} ${p.intents.length} intents · ${p.prompts} prompts × ${p.models.join(", ")} · about $${p.quoteUsd.toFixed(2)}`);
|
|
1278
|
+
const missing = core.MODELS.filter((m) => !plans[0].models.includes(m));
|
|
1279
|
+
if (missing.length)
|
|
1280
|
+
console.log(c.dim(`Not asked (no key): ${missing.join(", ")}.`));
|
|
1281
|
+
}
|
|
1282
|
+
if (opts.dryRun) {
|
|
1283
|
+
if (g().json)
|
|
1284
|
+
printJson({ plans: plans.map(({ helper, ...p }) => ({ ...p, extractor: helper?.modelId })), totalUsd: total });
|
|
1285
|
+
else
|
|
1286
|
+
console.log(c.dim(`About $${total.toFixed(2)} on your own keys in all. Nothing was asked.`));
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
if (!opts.yes && !(await confirm(`Ask the assistants for about $${total.toFixed(2)} on your own keys?`)))
|
|
1290
|
+
return;
|
|
1291
|
+
const results = [];
|
|
1292
|
+
for (const p of plans) {
|
|
1293
|
+
const r = await scanLocal(core, project, p, process.env, { log: g().json ? undefined : (l) => console.log(c.dim(` ${p.country.toUpperCase()} ${l}`)) });
|
|
1294
|
+
results.push(r);
|
|
1295
|
+
}
|
|
1296
|
+
if (g().json) {
|
|
1297
|
+
printJson(results.map(({ data, ...r }) => ({ ...r, summary: data.summary })));
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
for (const r of results) {
|
|
1301
|
+
const s = r.data.summary;
|
|
1302
|
+
console.log(`${c.green("✓")} ${r.file.replace(project.root + "/", "")} ${c.green(`${s.winning} named`)} · ${c.yellow(`${s.weak} weak`)} · ${c.red(`${s.missing} missing`)} ${c.dim(`$${r.costUsd.toFixed(2)}`)}`);
|
|
1303
|
+
for (const i of r.data.intents.filter((x) => x.status === "missing").slice(0, 3))
|
|
1304
|
+
console.log(` ${c.red("missing")} "${i.label}"${i.namedInstead ? c.dim(`: they name ${i.namedInstead.slice(0, 3).join(", ")}`) : ""}`);
|
|
1305
|
+
for (const ch of r.changes)
|
|
1306
|
+
console.log(` ${c.yellow("changed")} ${ch}`);
|
|
1307
|
+
const failedBy = [...new Set(r.failed.map((f) => f.model))];
|
|
1308
|
+
for (const m of failedBy)
|
|
1309
|
+
console.log(` ${c.red("✗")} ${m}: ${r.failed.find((f) => f.model === m).error}`);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
const searchAds = program.command("search-ads").description("Apple Search Ads on your own API key: this app's spend, installs, keywords and search terms in appeeky/search-ads/");
|
|
1313
|
+
searchAds
|
|
1314
|
+
.command("pull")
|
|
1315
|
+
.description("Write spend and installs per day, keywords and search terms to appeeky/search-ads/ (the Search Ads key on your appstore CLI account, or ASA_* env)")
|
|
1316
|
+
.option("--days <n>", "How many days, up to yesterday", "30")
|
|
1317
|
+
.action(async (opts) => {
|
|
1318
|
+
const project = await load();
|
|
1319
|
+
const { label, source } = await searchAdsSource(project);
|
|
1320
|
+
let r;
|
|
1321
|
+
try {
|
|
1322
|
+
r = await pullSearchAds(project, new SearchAdsClient(source), new Date(), Number(opts.days) || 30);
|
|
1323
|
+
}
|
|
1324
|
+
catch (e) {
|
|
1325
|
+
const msg = e.message;
|
|
1326
|
+
if (label.startsWith("appstore") && /no Apple Search Ads key|Could not start|AUTH_ERROR/i.test(msg)) {
|
|
1327
|
+
throw new Error(`${msg}\nSave the Search Ads key on your App Store Connect CLI account (appstore accounts search-ads <account> --client-id … --team-id … --key-id … --private-key-path …), or set ASA_CLIENT_ID, ASA_TEAM_ID, ASA_KEY_ID and ASA_PRIVATE_KEY.`);
|
|
1328
|
+
}
|
|
1329
|
+
throw e;
|
|
1330
|
+
}
|
|
1331
|
+
if (g().json) {
|
|
1332
|
+
printJson(r);
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
const money = (n) => `${n.toFixed(2)}${r.currency ? ` ${r.currency}` : ""}`;
|
|
1336
|
+
if (r.campaigns.length === 0) {
|
|
1337
|
+
console.log(c.yellow(`No campaign for App Store id ${project.spec.stores.appStore?.appId} in org ${r.orgId}.`));
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
console.log(`${c.green("✓")} appeeky/search-ads/ ${r.campaigns.map((x) => x.name).join(", ")}`);
|
|
1341
|
+
console.log(` ${opts.days} days: ${money(r.last30.spend)} · ${r.last30.installs} installs${r.last30.installs ? ` · ${money(r.last30.spend / r.last30.installs)} each` : ""} · ${r.last30.taps} taps`);
|
|
1342
|
+
console.log(c.dim(` ${r.keywords} keywords, ${r.searchTerms} search terms. Read appeeky/search-ads/summary.md.`));
|
|
1343
|
+
for (const e of r.errors)
|
|
1344
|
+
console.log(` ${c.yellow("!")} ${e}`);
|
|
1345
|
+
});
|
|
1346
|
+
const seo = program.command("seo").description("The app's website in Google and in AI answers: check it, write llms.txt, and pull Search Console with your own service account");
|
|
1347
|
+
const siteUrl = (project, url) => {
|
|
1348
|
+
const u = url ?? project.spec.web?.url;
|
|
1349
|
+
if (!u)
|
|
1350
|
+
throw new Error("Pass the site's URL, or set web.url in appeeky.spec.yaml.");
|
|
1351
|
+
return u;
|
|
1352
|
+
};
|
|
1353
|
+
seo
|
|
1354
|
+
.command("check")
|
|
1355
|
+
.description("Read the site like a crawler: what is missing for search, for the app and for AI assistants")
|
|
1356
|
+
.argument("[url]", "Page to check (default: web.url)")
|
|
1357
|
+
.action(async (url) => {
|
|
1358
|
+
const project = await load();
|
|
1359
|
+
const target = siteUrl(project, url);
|
|
1360
|
+
const checks = await checkSite(project, target);
|
|
1361
|
+
if (g().json) {
|
|
1362
|
+
printJson({ url: target, checks });
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
const titles = { search: "Search", app: "The app", ai: "AI assistants" };
|
|
1366
|
+
for (const area of ["search", "app", "ai"]) {
|
|
1367
|
+
const list = checks.filter((x) => x.area === area);
|
|
1368
|
+
if (!list.length)
|
|
1369
|
+
continue;
|
|
1370
|
+
console.log(c.bold(`\n${titles[area]}`));
|
|
1371
|
+
for (const x of list) {
|
|
1372
|
+
const mark = x.level === "ok" ? c.green("✓") : x.level === "warn" ? c.yellow("!") : c.red("✗");
|
|
1373
|
+
console.log(` ${mark} ${x.message}${x.fix ? c.dim(`\n ${x.fix}`) : ""}`);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
const bad = checks.filter((x) => x.level !== "ok").length;
|
|
1377
|
+
console.log(c.dim(`\n${checks.length - bad} of ${checks.length} fine.`));
|
|
1378
|
+
if (checks.some((x) => x.level === "fail"))
|
|
1379
|
+
process.exitCode = 1;
|
|
1380
|
+
});
|
|
1381
|
+
const dfs = () => {
|
|
1382
|
+
const auth = readDataForSeo(process.env);
|
|
1383
|
+
if (!auth)
|
|
1384
|
+
throw new Error("Set DATAFORSEO_LOGIN and DATAFORSEO_PASSWORD (app.dataforseo.com → API access). DataForSEO bills each call.");
|
|
1385
|
+
return auth;
|
|
1386
|
+
};
|
|
1387
|
+
const quoted = async (project, what, opts) => {
|
|
1388
|
+
const sets = webKeywords(project);
|
|
1389
|
+
if (what !== "domain" && Object.keys(sets).length === 0)
|
|
1390
|
+
throw new Error("No keywords: add web.keywords.<country> (or targets) to appeeky.spec.yaml.");
|
|
1391
|
+
const usd = quoteSeo(project, what);
|
|
1392
|
+
const n = Object.values(sets).reduce((a, k) => a + k.length, 0);
|
|
1393
|
+
if (!g().json || opts.dryRun)
|
|
1394
|
+
console.log(`${what === "domain" ? `${project.spec.web?.url ?? ""}` : `${n} keywords in ${Object.keys(sets).map((x) => x.toUpperCase()).join(", ")}`} · about $${usd.toFixed(2)} on DataForSEO`);
|
|
1395
|
+
if (opts.dryRun) {
|
|
1396
|
+
if (!g().json)
|
|
1397
|
+
console.log(c.dim("Nothing was asked."));
|
|
1398
|
+
else
|
|
1399
|
+
printJson({ what, keywords: sets, quoteUsd: usd });
|
|
1400
|
+
return false;
|
|
1401
|
+
}
|
|
1402
|
+
return opts.yes || (await confirm(`Spend about $${usd.toFixed(2)} on DataForSEO?`));
|
|
1403
|
+
};
|
|
1404
|
+
seo
|
|
1405
|
+
.command("ranks")
|
|
1406
|
+
.description("Where the site and the app's store page rank on Google for each keyword, and whether the AI Overview cites the site (DataForSEO)")
|
|
1407
|
+
.option("--every <days>", "Skip when the last check is newer than <days> days (for a daily schedule)")
|
|
1408
|
+
.option("--dry-run", "Show the keywords and the cost, ask nothing", false)
|
|
1409
|
+
.option("-y, --yes", "Do not ask before spending", false)
|
|
1410
|
+
.action(async (opts) => {
|
|
1411
|
+
const project = await load();
|
|
1412
|
+
const auth = opts.dryRun ? null : dfs();
|
|
1413
|
+
if (opts.every) {
|
|
1414
|
+
const since = await daysSinceRanks(project.root);
|
|
1415
|
+
if (since !== null && since < Number(opts.every)) {
|
|
1416
|
+
if (!g().json)
|
|
1417
|
+
console.log(c.dim(`Checked ${since} day${since === 1 ? "" : "s"} ago; next in ${Number(opts.every) - since}.`));
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
if (!(await quoted(project, "ranks", opts)))
|
|
1422
|
+
return;
|
|
1423
|
+
const r = await pullRanks(project, auth);
|
|
1424
|
+
if (g().json) {
|
|
1425
|
+
printJson(r);
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
const ranked = r.ranks.filter((x) => x.position !== null);
|
|
1429
|
+
console.log(`${c.green("✓")} appeeky/seo/ranks.md ${ranked.length} of ${r.ranks.length} in the top 100 · ${r.ranks.filter((x) => x.position !== null && x.position <= 10).length} in the top 10 · AI Overview cites you ${r.ranks.filter((x) => x.aiCitesYou).length}× ${c.dim(`$${r.costUsd.toFixed(3)}`)}`);
|
|
1430
|
+
for (const x of r.ranks.slice(0, 10)) {
|
|
1431
|
+
const ai = x.aiOverview ? (x.aiCitesYou ? c.green("AI cites you") : c.dim(`AI cites ${x.aiCited.slice(0, 2).join(", ") || "no one"}`)) : "";
|
|
1432
|
+
console.log(` ${x.country.toUpperCase()} ${x.keyword.padEnd(28)} site ${String(x.position ?? "—").padEnd(4)} store page ${String(x.storePosition ?? "—").padEnd(4)} ${ai}`);
|
|
1433
|
+
}
|
|
1434
|
+
for (const ch of r.changes)
|
|
1435
|
+
console.log(` ${c.yellow("changed")} ${ch}`);
|
|
1436
|
+
for (const e of r.errors)
|
|
1437
|
+
console.log(` ${c.red("✗")} ${e}`);
|
|
1438
|
+
});
|
|
1439
|
+
seo
|
|
1440
|
+
.command("keywords")
|
|
1441
|
+
.description("Search volume, difficulty, CPC and intent for the keywords, and related ideas, into appeeky/seo/keywords.csv (DataForSEO)")
|
|
1442
|
+
.option("--dry-run", "Show the cost, ask nothing", false)
|
|
1443
|
+
.option("-y, --yes", "Do not ask before spending", false)
|
|
1444
|
+
.action(async (opts) => {
|
|
1445
|
+
const project = await load();
|
|
1446
|
+
const auth = opts.dryRun ? null : dfs();
|
|
1447
|
+
if (!(await quoted(project, "keywords", opts)))
|
|
1448
|
+
return;
|
|
1449
|
+
const r = await pullKeywords(project, auth);
|
|
1450
|
+
if (g().json) {
|
|
1451
|
+
printJson(r);
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
const tracked = r.rows.filter((x) => x.tracked);
|
|
1455
|
+
console.log(`${c.green("✓")} appeeky/seo/keywords.csv ${tracked.length} keywords, ${r.rows.length - tracked.length} ideas ${c.dim(`$${r.costUsd.toFixed(3)}`)}`);
|
|
1456
|
+
for (const x of tracked.slice(0, 8))
|
|
1457
|
+
console.log(` ${x.keyword.padEnd(30)} ${String(x.volume ?? "—").padStart(7)}/mo difficulty ${x.difficulty ?? "—"}`);
|
|
1458
|
+
const ideas = r.rows.filter((x) => !x.tracked && (x.volume ?? 0) > 0).slice(0, 5);
|
|
1459
|
+
if (ideas.length)
|
|
1460
|
+
console.log(c.dim(` ideas: ${ideas.map((x) => `${x.keyword} (${x.volume})`).join(", ")}`));
|
|
1461
|
+
});
|
|
1462
|
+
seo
|
|
1463
|
+
.command("domain")
|
|
1464
|
+
.description("What the site ranks for, its estimated visits and the sites it competes with, into appeeky/seo/domain.md (DataForSEO)")
|
|
1465
|
+
.option("--dry-run", "Show the cost, ask nothing", false)
|
|
1466
|
+
.option("-y, --yes", "Do not ask before spending", false)
|
|
1467
|
+
.action(async (opts) => {
|
|
1468
|
+
const project = await load();
|
|
1469
|
+
const auth = opts.dryRun ? null : dfs();
|
|
1470
|
+
if (!(await quoted(project, "domain", opts)))
|
|
1471
|
+
return;
|
|
1472
|
+
const { report, costUsd } = await pullDomain(project, auth);
|
|
1473
|
+
if (g().json) {
|
|
1474
|
+
printJson({ ...report, costUsd });
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
console.log(`${c.green("✓")} appeeky/seo/domain.md ${report.domain} ${report.country.toUpperCase()}: ${report.keywords} keywords · ${report.top10} in the top 10 · ~${report.traffic} visits/mo ${c.dim(`$${costUsd.toFixed(3)}`)}`);
|
|
1478
|
+
if (report.competitors.length)
|
|
1479
|
+
console.log(c.dim(` competes with ${report.competitors.slice(0, 5).map((x) => x.domain).join(", ")}`));
|
|
1480
|
+
});
|
|
1481
|
+
seo
|
|
1482
|
+
.command("llms")
|
|
1483
|
+
.description("Write llms.txt from the listing: what the app is, where to get it, and its description")
|
|
1484
|
+
.option("--out <file>", "Write it to a file, e.g. public/llms.txt (default: print it)")
|
|
1485
|
+
.action(async (opts) => {
|
|
1486
|
+
const project = await load();
|
|
1487
|
+
const text = llmsTxt(project);
|
|
1488
|
+
if (!opts.out) {
|
|
1489
|
+
process.stdout.write(text);
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
const file = resolve(g().cwd, opts.out);
|
|
1493
|
+
await mkdir(resolve(file, ".."), { recursive: true });
|
|
1494
|
+
await writeFile(file, text);
|
|
1495
|
+
if (!g().json)
|
|
1496
|
+
console.log(`${c.green("✓")} ${opts.out}`);
|
|
1497
|
+
});
|
|
1498
|
+
seo
|
|
1499
|
+
.command("pull")
|
|
1500
|
+
.description("Google Search Console clicks, impressions, queries and pages into appeeky/seo/ (needs GSC_CREDENTIALS)")
|
|
1501
|
+
.action(async () => {
|
|
1502
|
+
const project = await load();
|
|
1503
|
+
const sa = await readServiceAccount(process.env, { gcloud: true });
|
|
1504
|
+
if (!sa)
|
|
1505
|
+
throw new Error("Set GSC_CREDENTIALS to a Google service account JSON key (or its path) and add its email as a user of the property in Search Console, or log in on this machine: gcloud auth application-default login --scopes=https://www.googleapis.com/auth/webmasters.readonly,https://www.googleapis.com/auth/cloud-platform");
|
|
1506
|
+
if (!searchConsoleProperty(project))
|
|
1507
|
+
throw new Error("Set web.url in appeeky.spec.yaml first.");
|
|
1508
|
+
const r = await pullSearchConsole(project, sa);
|
|
1509
|
+
if (g().json) {
|
|
1510
|
+
printJson(r);
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
const pct = (a, b) => (b > 0 ? c.dim(` (${a >= b ? "+" : ""}${Math.round(((a - b) / b) * 100)}%)`) : "");
|
|
1514
|
+
console.log(`${c.green("✓")} appeeky/seo/ ${r.property}`);
|
|
1515
|
+
console.log(` last 28 days: ${r.last28.clicks} clicks${pct(r.last28.clicks, r.previous28.clicks)} · ${r.last28.impressions} impressions${pct(r.last28.impressions, r.previous28.impressions)}`);
|
|
1516
|
+
console.log(c.dim(` ${r.queries} queries, ${r.pages} pages. Read appeeky/seo/summary.md.`));
|
|
1517
|
+
});
|
|
953
1518
|
const category = program.command("category").description("Your rank in the category chart, one row a day in appeeky/category.csv");
|
|
954
1519
|
category
|
|
955
1520
|
.command("pull")
|
|
@@ -1130,9 +1695,17 @@ export function buildProgram() {
|
|
|
1130
1695
|
const a = api();
|
|
1131
1696
|
const attribution = attributionClients(project.spec, a);
|
|
1132
1697
|
const visibility = a ? visibilityMetricSource(a, project) : null;
|
|
1698
|
+
const localVisibility = localVisibilityMetricSource(project);
|
|
1699
|
+
const search = seoMetricSource(project.root);
|
|
1700
|
+
const ownAds = searchAdsMetricSource(project.root);
|
|
1701
|
+
const webRank = webRankMetricSource(project.root);
|
|
1133
1702
|
const metrics = combineSources([
|
|
1134
1703
|
...(a ? [metricSourceFor(a, project.spec)] : []),
|
|
1135
1704
|
...(visibility ? [visibility] : []),
|
|
1705
|
+
...(localVisibility ? [localVisibility] : []),
|
|
1706
|
+
...(search ? [search] : []),
|
|
1707
|
+
...(ownAds ? [ownAds] : []),
|
|
1708
|
+
...(webRank ? [webRank] : []),
|
|
1136
1709
|
categoryMetricSource(project.root),
|
|
1137
1710
|
reviewMetricSource(project.root),
|
|
1138
1711
|
...(hasClients(attribution) ? [attributionMetricSource(attribution)] : []),
|
|
@@ -1342,13 +1915,13 @@ export function buildProgram() {
|
|
|
1342
1915
|
});
|
|
1343
1916
|
program
|
|
1344
1917
|
.command("report")
|
|
1345
|
-
.description("Markdown for a pull request: listing changes, check results
|
|
1918
|
+
.description("Markdown for a pull request: listing changes, check results, coverage against a base ref, and the posts and clips a merge sends out")
|
|
1346
1919
|
.option("--base <ref>", "Git ref to compare with", "origin/main")
|
|
1347
1920
|
.option("-o, --output <file>", "Write the report to a file")
|
|
1348
1921
|
.action(async (opts) => {
|
|
1349
1922
|
const project = await load();
|
|
1350
1923
|
const base = await loadProjectAtRef(project.root, opts.base);
|
|
1351
|
-
const md = specReport(base, project);
|
|
1924
|
+
const md = specReport(base, project) + (await outboxReport(project.root));
|
|
1352
1925
|
if (opts.output)
|
|
1353
1926
|
await writeFile(opts.output, `${md}\n`);
|
|
1354
1927
|
else
|