@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/README.md +83 -3
- package/assets/assets/{index-CkNSldWM.css → index-DIAYCN3N.css} +1 -1
- package/assets/assets/index-DYQXN-Fe.js +204 -0
- package/assets/index.html +2 -2
- package/dist/{chunk-W6AJ2472.js → chunk-WHOSB4LV.js} +298 -20
- package/dist/cli.js +274 -29
- package/dist/index.js +1 -1
- package/package.json +5 -4
- package/assets/assets/index-DHoyZdlF.js +0 -63
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-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
1790
|
+
const providerName = parseProviderName(request.params.name);
|
|
1748
1791
|
const { apiKey, baseUrl, model } = request.body ?? {};
|
|
1749
|
-
|
|
1750
|
-
|
|
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(/&/g, "&");
|
|
3713
|
+
text2 = text2.replace(/</g, "<");
|
|
3714
|
+
text2 = text2.replace(/>/g, ">");
|
|
3715
|
+
text2 = text2.replace(/"/g, '"');
|
|
3716
|
+
text2 = text2.replace(/'/g, "'");
|
|
3717
|
+
text2 = text2.replace(/ /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
|
};
|