@ainyc/canonry 1.1.2 → 1.4.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/assets/index.html CHANGED
@@ -8,8 +8,8 @@
8
8
  content="Canonry is the AINYC monitoring dashboard for answer visibility and technical readiness."
9
9
  />
10
10
  <title>Canonry</title>
11
- <script type="module" crossorigin src="/assets/index-DHoyZdlF.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-CkNSldWM.css">
11
+ <script type="module" crossorigin src="/assets/index-DYQXN-Fe.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-DIAYCN3N.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -10,6 +10,10 @@ import path from "path";
10
10
  import os from "os";
11
11
  import { parse, stringify } from "yaml";
12
12
  function getConfigDir() {
13
+ const override = process.env.CANONRY_CONFIG_DIR?.trim();
14
+ if (override) {
15
+ return override;
16
+ }
13
17
  return path.join(os.homedir(), ".canonry");
14
18
  }
15
19
  function getConfigPath() {
@@ -39,12 +43,6 @@ function loadConfig() {
39
43
  }
40
44
  };
41
45
  }
42
- const hasProvider = parsed.providers && (parsed.providers.gemini?.apiKey || parsed.providers.openai?.apiKey || parsed.providers.claude?.apiKey || parsed.providers.local?.baseUrl);
43
- if (!hasProvider) {
44
- throw new Error(
45
- `No providers configured at ${configPath}. At least one provider is required.`
46
- );
47
- }
48
46
  return parsed;
49
47
  }
50
48
  function saveConfig(config) {
@@ -385,7 +383,12 @@ var providerQuotaPolicySchema = z.object({
385
383
  maxRequestsPerMinute: z.number().int().positive(),
386
384
  maxRequestsPerDay: z.number().int().positive()
387
385
  });
388
- var providerNameSchema = z.enum(["gemini", "openai", "claude", "local"]);
386
+ var PROVIDER_NAMES = ["gemini", "openai", "claude", "local"];
387
+ var providerNameSchema = z.enum(PROVIDER_NAMES);
388
+ function parseProviderName(input) {
389
+ const lower = input.trim().toLowerCase();
390
+ return PROVIDER_NAMES.includes(lower) ? lower : void 0;
391
+ }
389
392
 
390
393
  // ../contracts/src/notification.ts
391
394
  import { z as z2 } from "zod";
@@ -802,7 +805,7 @@ function formatProject(row) {
802
805
  // ../api-routes/src/keywords.ts
803
806
  import crypto4 from "crypto";
804
807
  import { eq as eq4 } from "drizzle-orm";
805
- async function keywordRoutes(app) {
808
+ async function keywordRoutes(app, opts) {
806
809
  app.get("/projects/:name/keywords", async (request, reply) => {
807
810
  const project = resolveProjectSafe(app, request.params.name, reply);
808
811
  if (!project) return;
@@ -875,6 +878,45 @@ async function keywordRoutes(app) {
875
878
  const rows = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
876
879
  return reply.send(rows.map((r) => ({ id: r.id, keyword: r.keyword, createdAt: r.createdAt })));
877
880
  });
881
+ app.post("/projects/:name/keywords/generate", async (request, reply) => {
882
+ const project = resolveProjectSafe(app, request.params.name, reply);
883
+ if (!project) return;
884
+ const body = request.body;
885
+ if (!body?.provider || typeof body.provider !== "string") {
886
+ const err = validationError('Body must contain a "provider" string');
887
+ return reply.status(err.statusCode).send(err.toJSON());
888
+ }
889
+ const provider = parseProviderName(body.provider);
890
+ if (!provider) {
891
+ const err = validationError(`Unknown provider "${body.provider}". Valid providers: gemini, openai, claude, local`);
892
+ return reply.status(err.statusCode).send(err.toJSON());
893
+ }
894
+ if (body.count !== void 0 && (typeof body.count !== "number" || !Number.isFinite(body.count) || !Number.isInteger(body.count))) {
895
+ const err = validationError('"count" must be an integer');
896
+ return reply.status(err.statusCode).send(err.toJSON());
897
+ }
898
+ const count = Math.min(Math.max(body.count ?? 5, 1), 20);
899
+ if (!opts.onGenerateKeywords) {
900
+ return reply.status(501).send({ error: "Key phrase generation is not supported in this deployment" });
901
+ }
902
+ const existingRows = app.db.select().from(keywords).where(eq4(keywords.projectId, project.id)).all();
903
+ const existingKeywords = existingRows.map((r) => r.keyword);
904
+ try {
905
+ const generated = await opts.onGenerateKeywords(provider, count, {
906
+ domain: project.canonicalDomain,
907
+ displayName: project.displayName,
908
+ country: project.country,
909
+ language: project.language,
910
+ existingKeywords
911
+ });
912
+ return reply.send({ keywords: generated, provider });
913
+ } catch (err) {
914
+ request.log.error({ err }, "Key phrase generation failed");
915
+ return reply.status(500).send({
916
+ error: err instanceof Error ? err.message : "Failed to generate key phrases"
917
+ });
918
+ }
919
+ });
878
920
  }
879
921
  function resolveProjectSafe(app, name, reply) {
880
922
  try {
@@ -989,12 +1031,13 @@ async function runRoutes(app, opts) {
989
1031
  const now = (/* @__PURE__ */ new Date()).toISOString();
990
1032
  const trigger = request.body?.trigger ?? "manual";
991
1033
  const rawProviders = request.body?.providers;
992
- const validProviders = ["gemini", "openai", "claude", "local"];
993
1034
  if (rawProviders?.length) {
994
- const invalid = rawProviders.filter((p) => !validProviders.includes(p));
1035
+ const parsed = rawProviders.map((p) => parseProviderName(p));
1036
+ const invalid = rawProviders.filter((_, i) => !parsed[i]);
995
1037
  if (invalid.length) {
996
- return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: `Invalid provider(s): ${invalid.join(", ")}. Must be one of: ${validProviders.join(", ")}` } });
1038
+ return reply.status(400).send({ error: { code: "VALIDATION_ERROR", message: `Invalid provider(s): ${invalid.join(", ")}. Must be one of: gemini, openai, claude, local` } });
997
1039
  }
1040
+ rawProviders.splice(0, rawProviders.length, ...parsed.filter(Boolean));
998
1041
  }
999
1042
  const providers = rawProviders?.length ? rawProviders : void 0;
1000
1043
  const queueResult = queueRunIfProjectIdle(app.db, {
@@ -1744,12 +1787,12 @@ async function settingsRoutes(app, opts) {
1744
1787
  providers: opts.providerSummary ?? []
1745
1788
  }));
1746
1789
  app.put("/settings/providers/:name", async (request, reply) => {
1747
- const { name } = request.params;
1790
+ const providerName = parseProviderName(request.params.name);
1748
1791
  const { apiKey, baseUrl, model } = request.body ?? {};
1749
- const validProviders = ["gemini", "openai", "claude", "local"];
1750
- if (!validProviders.includes(name)) {
1751
- return reply.status(400).send({ error: `Invalid provider: ${name}. Must be one of: ${validProviders.join(", ")}` });
1792
+ if (!providerName) {
1793
+ return reply.status(400).send({ error: `Invalid provider: ${request.params.name}. Must be one of: gemini, openai, claude, local` });
1752
1794
  }
1795
+ const name = providerName;
1753
1796
  if (name === "local") {
1754
1797
  if (!baseUrl || typeof baseUrl !== "string") {
1755
1798
  return reply.status(400).send({ error: "baseUrl is required for local provider" });
@@ -2074,7 +2117,9 @@ async function apiRoutes(app, opts) {
2074
2117
  await api.register(projectRoutes, {
2075
2118
  onProjectDeleted: opts.onProjectDeleted
2076
2119
  });
2077
- await api.register(keywordRoutes);
2120
+ await api.register(keywordRoutes, {
2121
+ onGenerateKeywords: opts.onGenerateKeywords
2122
+ });
2078
2123
  await api.register(competitorRoutes);
2079
2124
  await api.register(runRoutes, { onRunCreated: opts.onRunCreated });
2080
2125
  await api.register(applyRoutes, {
@@ -2254,6 +2299,13 @@ function extractDomainFromUri(uri) {
2254
2299
  return null;
2255
2300
  }
2256
2301
  }
2302
+ async function generateText(prompt, config) {
2303
+ const model = resolveModel(config);
2304
+ const genAI = new GoogleGenerativeAI(config.apiKey);
2305
+ const generativeModel = genAI.getGenerativeModel({ model });
2306
+ const result = await generativeModel.generateContent(prompt);
2307
+ return result.response.text();
2308
+ }
2257
2309
  function responseToRecord(response) {
2258
2310
  try {
2259
2311
  const candidates = response.candidates?.map((c) => ({
@@ -2332,6 +2384,9 @@ var geminiAdapter = {
2332
2384
  groundingSources: normalized.groundingSources,
2333
2385
  searchQueries: normalized.searchQueries
2334
2386
  };
2387
+ },
2388
+ async generateText(prompt, config) {
2389
+ return generateText(prompt, toGeminiConfig(config));
2335
2390
  }
2336
2391
  };
2337
2392
 
@@ -2494,6 +2549,15 @@ function extractDomainFromUri2(uri) {
2494
2549
  return null;
2495
2550
  }
2496
2551
  }
2552
+ async function generateText2(prompt, config) {
2553
+ const model = config.model ?? DEFAULT_MODEL2;
2554
+ const client = new OpenAI({ apiKey: config.apiKey });
2555
+ const response = await client.chat.completions.create({
2556
+ model,
2557
+ messages: [{ role: "user", content: prompt }]
2558
+ });
2559
+ return response.choices[0]?.message?.content ?? "";
2560
+ }
2497
2561
  function responseToRecord2(response) {
2498
2562
  try {
2499
2563
  return JSON.parse(JSON.stringify(response));
@@ -2561,6 +2625,9 @@ var openaiAdapter = {
2561
2625
  groundingSources: normalized.groundingSources,
2562
2626
  searchQueries: normalized.searchQueries
2563
2627
  };
2628
+ },
2629
+ async generateText(prompt, config) {
2630
+ return generateText2(prompt, toOpenAIConfig(config));
2564
2631
  }
2565
2632
  };
2566
2633
 
@@ -2718,6 +2785,16 @@ function extractDomainFromUri3(uri) {
2718
2785
  return null;
2719
2786
  }
2720
2787
  }
2788
+ async function generateText3(prompt, config) {
2789
+ const model = config.model ?? DEFAULT_MODEL3;
2790
+ const client = new Anthropic({ apiKey: config.apiKey });
2791
+ const response = await client.messages.create({
2792
+ model,
2793
+ max_tokens: 2048,
2794
+ messages: [{ role: "user", content: prompt }]
2795
+ });
2796
+ return extractTextFromResponse(response);
2797
+ }
2721
2798
  function responseToRecord3(response) {
2722
2799
  try {
2723
2800
  return JSON.parse(JSON.stringify(response));
@@ -2785,6 +2862,9 @@ var claudeAdapter = {
2785
2862
  groundingSources: normalized.groundingSources,
2786
2863
  searchQueries: normalized.searchQueries
2787
2864
  };
2865
+ },
2866
+ async generateText(prompt, config) {
2867
+ return generateText3(prompt, toClaudeConfig(config));
2788
2868
  }
2789
2869
  };
2790
2870
 
@@ -2881,6 +2961,18 @@ function extractAnswerText2(rawResponse) {
2881
2961
  return "";
2882
2962
  }
2883
2963
  }
2964
+ async function generateText4(prompt, config) {
2965
+ const model = config.model ?? DEFAULT_MODEL4;
2966
+ const client = new OpenAI2({
2967
+ baseURL: config.baseUrl,
2968
+ apiKey: config.apiKey || "not-needed"
2969
+ });
2970
+ const response = await client.chat.completions.create({
2971
+ model,
2972
+ messages: [{ role: "user", content: prompt }]
2973
+ });
2974
+ return response.choices[0]?.message?.content ?? "";
2975
+ }
2884
2976
  function extractDomainMentions(text2) {
2885
2977
  const domains = /* @__PURE__ */ new Set();
2886
2978
  const urlPattern = /https?:\/\/([a-zA-Z0-9][-a-zA-Z0-9]*(?:\.[a-zA-Z0-9][-a-zA-Z0-9]*)+)/g;
@@ -2955,12 +3047,15 @@ var localAdapter = {
2955
3047
  groundingSources: normalized.groundingSources,
2956
3048
  searchQueries: normalized.searchQueries
2957
3049
  };
3050
+ },
3051
+ async generateText(prompt, config) {
3052
+ return generateText4(prompt, toLocalConfig(config));
2958
3053
  }
2959
3054
  };
2960
3055
 
2961
3056
  // src/job-runner.ts
2962
3057
  import crypto11 from "crypto";
2963
- import { eq as eq12 } from "drizzle-orm";
3058
+ import { eq as eq12, inArray as inArray2 } from "drizzle-orm";
2964
3059
  var JobRunner = class {
2965
3060
  db;
2966
3061
  registry;
@@ -2969,6 +3064,15 @@ var JobRunner = class {
2969
3064
  this.db = db;
2970
3065
  this.registry = registry;
2971
3066
  }
3067
+ recoverStaleRuns() {
3068
+ const stale = this.db.select({ id: runs.id, status: runs.status }).from(runs).where(inArray2(runs.status, ["running", "queued"])).all();
3069
+ if (stale.length === 0) return;
3070
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3071
+ for (const run of stale) {
3072
+ this.db.update(runs).set({ status: "failed", finishedAt: now, error: "Server restarted while run was in progress" }).where(eq12(runs.id, run.id)).run();
3073
+ console.log(`[JobRunner] Recovered stale run ${run.id} (was ${run.status})`);
3074
+ }
3075
+ }
2972
3076
  async executeRun(runId, projectId, providerOverride) {
2973
3077
  const now = (/* @__PURE__ */ new Date()).toISOString();
2974
3078
  try {
@@ -3507,6 +3611,117 @@ var Notifier = class {
3507
3611
  }
3508
3612
  };
3509
3613
 
3614
+ // src/site-fetch.ts
3615
+ import https2 from "https";
3616
+ var FETCH_TIMEOUT_MS = 1e4;
3617
+ var MAX_TEXT_LENGTH = 4e3;
3618
+ var MAX_BODY_BYTES = 512e3;
3619
+ var USER_AGENT = "Canonry/1.0 (site-analysis)";
3620
+ function extractHostname(domain) {
3621
+ let hostname = domain;
3622
+ try {
3623
+ if (hostname.includes("://")) {
3624
+ hostname = new URL(hostname).hostname;
3625
+ }
3626
+ } catch {
3627
+ }
3628
+ return hostname.replace(/^www\./, "");
3629
+ }
3630
+ function fetchWithPinnedAddress(target) {
3631
+ return new Promise((resolve) => {
3632
+ const port = target.url.port ? Number(target.url.port) : 443;
3633
+ const path3 = target.url.pathname + target.url.search;
3634
+ const req = https2.request(
3635
+ {
3636
+ hostname: target.address,
3637
+ family: target.family,
3638
+ port,
3639
+ path: path3,
3640
+ method: "GET",
3641
+ timeout: FETCH_TIMEOUT_MS,
3642
+ servername: target.url.hostname,
3643
+ // SNI for TLS
3644
+ headers: {
3645
+ Host: target.url.host,
3646
+ "User-Agent": USER_AGENT,
3647
+ Accept: "text/html"
3648
+ }
3649
+ },
3650
+ (res) => {
3651
+ if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
3652
+ if (res.statusCode >= 300 && res.statusCode < 400) {
3653
+ const location = res.headers.location ?? "";
3654
+ res.resume();
3655
+ resolve(`REDIRECT:${location}`);
3656
+ return;
3657
+ }
3658
+ res.resume();
3659
+ resolve("");
3660
+ return;
3661
+ }
3662
+ const contentType = res.headers["content-type"] ?? "";
3663
+ if (!contentType.includes("text/html")) {
3664
+ res.resume();
3665
+ resolve("");
3666
+ return;
3667
+ }
3668
+ const chunks = [];
3669
+ let totalBytes = 0;
3670
+ res.on("data", (chunk) => {
3671
+ totalBytes += chunk.length;
3672
+ if (totalBytes <= MAX_BODY_BYTES) {
3673
+ chunks.push(chunk);
3674
+ }
3675
+ });
3676
+ res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
3677
+ res.on("error", () => resolve(""));
3678
+ }
3679
+ );
3680
+ req.on("timeout", () => req.destroy(new Error("timeout")));
3681
+ req.on("error", () => resolve(""));
3682
+ req.end();
3683
+ });
3684
+ }
3685
+ async function fetchSiteText(domain) {
3686
+ const hostname = extractHostname(domain);
3687
+ const url = `https://${hostname}`;
3688
+ const targetCheck = await resolveWebhookTarget(url);
3689
+ if (!targetCheck.ok) return "";
3690
+ try {
3691
+ const result = await fetchWithPinnedAddress(targetCheck.target);
3692
+ if (result.startsWith("REDIRECT:")) {
3693
+ const location = result.slice("REDIRECT:".length);
3694
+ if (!location) return "";
3695
+ const redirectUrl = new URL(location, url).href;
3696
+ const redirectCheck = await resolveWebhookTarget(redirectUrl);
3697
+ if (!redirectCheck.ok) return "";
3698
+ const redirectResult = await fetchWithPinnedAddress(redirectCheck.target);
3699
+ if (redirectResult.startsWith("REDIRECT:")) return "";
3700
+ return stripHtml(redirectResult);
3701
+ }
3702
+ return stripHtml(result);
3703
+ } catch {
3704
+ return "";
3705
+ }
3706
+ }
3707
+ function stripHtml(html) {
3708
+ if (!html) return "";
3709
+ let text2 = html.replace(/<script[\s\S]*?<\/script>/gi, " ");
3710
+ text2 = text2.replace(/<style[\s\S]*?<\/style>/gi, " ");
3711
+ text2 = text2.replace(/<[^>]+>/g, " ");
3712
+ text2 = text2.replace(/&amp;/g, "&");
3713
+ text2 = text2.replace(/&lt;/g, "<");
3714
+ text2 = text2.replace(/&gt;/g, ">");
3715
+ text2 = text2.replace(/&quot;/g, '"');
3716
+ text2 = text2.replace(/&#39;/g, "'");
3717
+ text2 = text2.replace(/&nbsp;/g, " ");
3718
+ text2 = text2.replace(/\s+/g, " ").trim();
3719
+ if (text2.length > MAX_TEXT_LENGTH) {
3720
+ text2 = text2.slice(0, MAX_TEXT_LENGTH);
3721
+ }
3722
+ return text2;
3723
+ }
3724
+
3510
3725
  // src/server.ts
3511
3726
  var _require = createRequire(import.meta.url);
3512
3727
  var { version: PKG_VERSION } = _require("../package.json");
@@ -3578,6 +3793,7 @@ async function createServer(opts) {
3578
3793
  const port = opts.config.port ?? 4100;
3579
3794
  const serverUrl = `http://localhost:${port}`;
3580
3795
  const jobRunner = new JobRunner(opts.db, registry);
3796
+ jobRunner.recoverStaleRuns();
3581
3797
  const notifier = new Notifier(opts.db, serverUrl);
3582
3798
  jobRunner.onRunCompleted = (runId, projectId) => notifier.onRunCompleted(runId, projectId);
3583
3799
  const scheduler = new Scheduler(opts.db, {
@@ -3647,6 +3863,22 @@ async function createServer(opts) {
3647
3863
  },
3648
3864
  onProjectDeleted: (projectId) => {
3649
3865
  scheduler.remove(projectId);
3866
+ },
3867
+ onGenerateKeywords: async (providerName, count, project) => {
3868
+ const provider = registry.get(providerName);
3869
+ if (!provider) throw new Error(`Provider "${providerName}" is not configured`);
3870
+ const siteText = await fetchSiteText(project.domain);
3871
+ const prompt = buildKeywordGenerationPrompt({
3872
+ domain: project.domain,
3873
+ displayName: project.displayName,
3874
+ country: project.country,
3875
+ language: project.language,
3876
+ existingKeywords: project.existingKeywords,
3877
+ siteText,
3878
+ count
3879
+ });
3880
+ const raw = await provider.adapter.generateText(prompt, provider.config);
3881
+ return parseKeywordResponse(raw, count);
3650
3882
  }
3651
3883
  });
3652
3884
  const dirname2 = path2.dirname(fileURLToPath(import.meta.url));
@@ -3695,15 +3927,61 @@ async function createServer(opts) {
3695
3927
  });
3696
3928
  return app;
3697
3929
  }
3930
+ function buildKeywordGenerationPrompt(ctx) {
3931
+ const lines = [
3932
+ "You are an SEO and AEO (Answer Engine Optimization) expert. Given a website's content, generate search queries that potential users would type into AI answer engines (ChatGPT, Gemini, Claude) to find services, products, or information like what this site offers.",
3933
+ "",
3934
+ `Website: ${ctx.domain}`
3935
+ ];
3936
+ if (ctx.displayName) lines.push(`Business: ${ctx.displayName}`);
3937
+ lines.push(`Country: ${ctx.country}`);
3938
+ lines.push(`Language: ${ctx.language}`);
3939
+ if (ctx.siteText) {
3940
+ lines.push("", "--- Site Content ---", ctx.siteText, "--- End Site Content ---");
3941
+ }
3942
+ if (ctx.existingKeywords.length > 0) {
3943
+ lines.push("", `Already tracking (do NOT duplicate): ${ctx.existingKeywords.join(", ")}`);
3944
+ }
3945
+ lines.push(
3946
+ "",
3947
+ `Generate exactly ${ctx.count} key phrases that:`,
3948
+ '- Are short and concise (2-5 words each, like "best dentist brooklyn" not "what is the best dentist office in the brooklyn area for families")',
3949
+ "- Are natural phrases people would type into AI answer engines",
3950
+ "- Cover different intents (informational, transactional, navigational)",
3951
+ `- Are relevant to the ${ctx.country} market in ${ctx.language}`,
3952
+ "- Reflect the actual services/products/content found on the site",
3953
+ "",
3954
+ "Return ONLY the key phrases, one per line, no numbering or bullets."
3955
+ );
3956
+ return lines.join("\n");
3957
+ }
3958
+ function parseKeywordResponse(raw, count) {
3959
+ const seen = /* @__PURE__ */ new Set();
3960
+ const results = [];
3961
+ for (const line of raw.split("\n")) {
3962
+ let cleaned = line.replace(/^\s*(?:\d+[.)]\s*|[-*•]\s*)/, "").trim();
3963
+ cleaned = cleaned.replace(/^["']|["']$/g, "").trim();
3964
+ if (!cleaned) continue;
3965
+ if (/^(here are|sure|certainly|of course|i've|these are|below are)/i.test(cleaned)) continue;
3966
+ if (cleaned.split(/\s+/).length > 8) continue;
3967
+ const key = cleaned.toLowerCase();
3968
+ if (seen.has(key)) continue;
3969
+ seen.add(key);
3970
+ results.push(cleaned);
3971
+ if (results.length >= count) break;
3972
+ }
3973
+ return results;
3974
+ }
3698
3975
 
3699
3976
  export {
3977
+ providerQuotaPolicySchema,
3978
+ apiKeys,
3979
+ createClient,
3980
+ migrate,
3700
3981
  getConfigDir,
3701
3982
  getConfigPath,
3702
3983
  loadConfig,
3703
3984
  saveConfig,
3704
3985
  configExists,
3705
- apiKeys,
3706
- createClient,
3707
- migrate,
3708
3986
  createServer
3709
3987
  };