@ainyc/canonry 1.0.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/LICENSE +661 -0
- package/assets/assets/index-CkNSldWM.css +1 -0
- package/assets/assets/index-DHoyZdlF.js +63 -0
- package/assets/index.html +17 -0
- package/bin/canonry.mjs +2 -0
- package/dist/chunk-ONZDY6Q4.js +3706 -0
- package/dist/cli.js +1101 -0
- package/dist/index.js +8 -0
- package/package.json +58 -0
- package/src/cli.ts +470 -0
- package/src/client.ts +152 -0
- package/src/commands/apply.ts +25 -0
- package/src/commands/competitor.ts +36 -0
- package/src/commands/evidence.ts +41 -0
- package/src/commands/export-cmd.ts +40 -0
- package/src/commands/history.ts +41 -0
- package/src/commands/init.ts +122 -0
- package/src/commands/keyword.ts +54 -0
- package/src/commands/notify.ts +70 -0
- package/src/commands/project.ts +89 -0
- package/src/commands/run.ts +54 -0
- package/src/commands/schedule.ts +90 -0
- package/src/commands/serve.ts +24 -0
- package/src/commands/settings.ts +45 -0
- package/src/commands/status.ts +52 -0
- package/src/config.ts +90 -0
- package/src/index.ts +2 -0
- package/src/job-runner.ts +368 -0
- package/src/notifier.ts +227 -0
- package/src/provider-registry.ts +55 -0
- package/src/scheduler.ts +161 -0
- package/src/server.ts +249 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { loadConfig } from '../config.js'
|
|
2
|
+
import { ApiClient } from '../client.js'
|
|
3
|
+
|
|
4
|
+
function getClient(): ApiClient {
|
|
5
|
+
const config = loadConfig()
|
|
6
|
+
return new ApiClient(config.apiUrl, config.apiKey)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function setProvider(name: string, opts: { apiKey?: string; baseUrl?: string; model?: string }): Promise<void> {
|
|
10
|
+
const client = getClient()
|
|
11
|
+
const result = await client.updateProvider(name, opts) as {
|
|
12
|
+
name: string
|
|
13
|
+
model?: string
|
|
14
|
+
configured: boolean
|
|
15
|
+
}
|
|
16
|
+
console.log(`Provider ${result.name} updated successfully.`)
|
|
17
|
+
if (result.model) {
|
|
18
|
+
console.log(` Model: ${result.model}`)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function showSettings(): Promise<void> {
|
|
23
|
+
const client = getClient()
|
|
24
|
+
const settings = await client.getSettings() as {
|
|
25
|
+
providers: Array<{
|
|
26
|
+
name: string
|
|
27
|
+
model?: string
|
|
28
|
+
configured: boolean
|
|
29
|
+
quota?: { maxConcurrency: number; maxRequestsPerMinute: number; maxRequestsPerDay: number }
|
|
30
|
+
}>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
console.log('Provider settings:\n')
|
|
34
|
+
|
|
35
|
+
for (const provider of settings.providers) {
|
|
36
|
+
const status = provider.configured ? 'configured' : 'not configured'
|
|
37
|
+
console.log(` ${provider.name.padEnd(10)} ${status}`)
|
|
38
|
+
if (provider.configured) {
|
|
39
|
+
console.log(` Model: ${provider.model ?? '(default)'}`)
|
|
40
|
+
if (provider.quota) {
|
|
41
|
+
console.log(` Quota: ${provider.quota.maxConcurrency} concurrent · ${provider.quota.maxRequestsPerMinute}/min · ${provider.quota.maxRequestsPerDay}/day`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { loadConfig } from '../config.js'
|
|
2
|
+
import { ApiClient } from '../client.js'
|
|
3
|
+
|
|
4
|
+
function getClient(): ApiClient {
|
|
5
|
+
const config = loadConfig()
|
|
6
|
+
return new ApiClient(config.apiUrl, config.apiKey)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function showStatus(project: string): Promise<void> {
|
|
10
|
+
const client = getClient()
|
|
11
|
+
const projectData = await client.getProject(project) as {
|
|
12
|
+
id: string
|
|
13
|
+
name: string
|
|
14
|
+
displayName: string
|
|
15
|
+
canonicalDomain: string
|
|
16
|
+
country: string
|
|
17
|
+
language: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
console.log(`Status: ${projectData.displayName} (${projectData.name})\n`)
|
|
21
|
+
console.log(` Domain: ${projectData.canonicalDomain}`)
|
|
22
|
+
console.log(` Country: ${projectData.country}`)
|
|
23
|
+
console.log(` Language: ${projectData.language}`)
|
|
24
|
+
|
|
25
|
+
// Try to get latest run info
|
|
26
|
+
try {
|
|
27
|
+
const runs = await client.listRuns(project) as Array<{
|
|
28
|
+
id: string
|
|
29
|
+
status: string
|
|
30
|
+
kind: string
|
|
31
|
+
createdAt: string
|
|
32
|
+
finishedAt: string | null
|
|
33
|
+
}>
|
|
34
|
+
|
|
35
|
+
if (runs.length > 0) {
|
|
36
|
+
const latest = runs[runs.length - 1]!
|
|
37
|
+
console.log(`\n Latest run:`)
|
|
38
|
+
console.log(` ID: ${latest.id}`)
|
|
39
|
+
console.log(` Status: ${latest.status}`)
|
|
40
|
+
console.log(` Created: ${latest.createdAt}`)
|
|
41
|
+
if (latest.finishedAt) {
|
|
42
|
+
console.log(` Finished: ${latest.finishedAt}`)
|
|
43
|
+
}
|
|
44
|
+
console.log(`\n Total runs: ${runs.length}`)
|
|
45
|
+
} else {
|
|
46
|
+
console.log('\n No runs yet. Use "canonry run" to trigger one.')
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// Runs endpoint may not be available
|
|
50
|
+
console.log('\n Run info unavailable.')
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import { parse, stringify } from 'yaml'
|
|
5
|
+
import type { ProviderQuotaPolicy } from '@ainyc/canonry-contracts'
|
|
6
|
+
|
|
7
|
+
export interface ProviderConfigEntry {
|
|
8
|
+
apiKey?: string
|
|
9
|
+
baseUrl?: string
|
|
10
|
+
model?: string
|
|
11
|
+
quota?: ProviderQuotaPolicy
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CanonryConfig {
|
|
15
|
+
apiUrl: string
|
|
16
|
+
database: string
|
|
17
|
+
apiKey: string
|
|
18
|
+
port?: number
|
|
19
|
+
// Legacy single-provider fields (backward compat)
|
|
20
|
+
geminiApiKey?: string
|
|
21
|
+
geminiModel?: string
|
|
22
|
+
geminiQuota?: ProviderQuotaPolicy
|
|
23
|
+
// Multi-provider config
|
|
24
|
+
providers?: {
|
|
25
|
+
gemini?: ProviderConfigEntry
|
|
26
|
+
openai?: ProviderConfigEntry
|
|
27
|
+
claude?: ProviderConfigEntry
|
|
28
|
+
local?: ProviderConfigEntry
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getConfigDir(): string {
|
|
33
|
+
return path.join(os.homedir(), '.canonry')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getConfigPath(): string {
|
|
37
|
+
return path.join(getConfigDir(), 'config.yaml')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function loadConfig(): CanonryConfig {
|
|
41
|
+
const configPath = getConfigPath()
|
|
42
|
+
if (!fs.existsSync(configPath)) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Config not found at ${configPath}. Run "canonry init" to create one.`,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
const raw = fs.readFileSync(configPath, 'utf-8')
|
|
48
|
+
const parsed = parse(raw) as CanonryConfig
|
|
49
|
+
if (!parsed.apiUrl || !parsed.database || !parsed.apiKey) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Invalid config at ${configPath}. Required fields: apiUrl, database, apiKey`,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Migrate legacy geminiApiKey to providers map
|
|
56
|
+
if (parsed.geminiApiKey && !parsed.providers?.gemini) {
|
|
57
|
+
parsed.providers = {
|
|
58
|
+
...parsed.providers,
|
|
59
|
+
gemini: {
|
|
60
|
+
apiKey: parsed.geminiApiKey,
|
|
61
|
+
model: parsed.geminiModel,
|
|
62
|
+
quota: parsed.geminiQuota,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Must have at least one provider configured
|
|
68
|
+
const hasProvider = parsed.providers &&
|
|
69
|
+
(parsed.providers.gemini?.apiKey || parsed.providers.openai?.apiKey || parsed.providers.claude?.apiKey || parsed.providers.local?.baseUrl)
|
|
70
|
+
if (!hasProvider) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`No providers configured at ${configPath}. At least one provider is required.`,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return parsed
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function saveConfig(config: CanonryConfig): void {
|
|
80
|
+
const configDir = getConfigDir()
|
|
81
|
+
if (!fs.existsSync(configDir)) {
|
|
82
|
+
fs.mkdirSync(configDir, { recursive: true })
|
|
83
|
+
}
|
|
84
|
+
const yaml = stringify(config)
|
|
85
|
+
fs.writeFileSync(getConfigPath(), yaml, { encoding: 'utf-8', mode: 0o600 })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function configExists(): boolean {
|
|
89
|
+
return fs.existsSync(getConfigPath())
|
|
90
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import { eq } from 'drizzle-orm'
|
|
3
|
+
import type { DatabaseClient } from '@ainyc/canonry-db'
|
|
4
|
+
import { runs, keywords, competitors, projects, querySnapshots, usageCounters } from '@ainyc/canonry-db'
|
|
5
|
+
import type { ProviderName, NormalizedQueryResult } from '@ainyc/canonry-contracts'
|
|
6
|
+
import type { ProviderRegistry, RegisteredProvider } from './provider-registry.js'
|
|
7
|
+
|
|
8
|
+
export class JobRunner {
|
|
9
|
+
private db: DatabaseClient
|
|
10
|
+
private registry: ProviderRegistry
|
|
11
|
+
onRunCompleted?: (runId: string, projectId: string) => Promise<void>
|
|
12
|
+
|
|
13
|
+
constructor(db: DatabaseClient, registry: ProviderRegistry) {
|
|
14
|
+
this.db = db
|
|
15
|
+
this.registry = registry
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async executeRun(runId: string, projectId: string, providerOverride?: ProviderName[]): Promise<void> {
|
|
19
|
+
const now = new Date().toISOString()
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
// Mark run as running
|
|
23
|
+
this.db
|
|
24
|
+
.update(runs)
|
|
25
|
+
.set({ status: 'running', startedAt: now })
|
|
26
|
+
.where(eq(runs.id, runId))
|
|
27
|
+
.run()
|
|
28
|
+
|
|
29
|
+
// Fetch project
|
|
30
|
+
const project = this.db
|
|
31
|
+
.select()
|
|
32
|
+
.from(projects)
|
|
33
|
+
.where(eq(projects.id, projectId))
|
|
34
|
+
.get()
|
|
35
|
+
|
|
36
|
+
if (!project) {
|
|
37
|
+
throw new Error(`Project ${projectId} not found`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Resolve which providers to use — honour per-run override, then project config
|
|
41
|
+
const projectProviders = providerOverride ?? (JSON.parse(project.providers || '[]') as ProviderName[])
|
|
42
|
+
const activeProviders = this.registry.getForProject(projectProviders)
|
|
43
|
+
|
|
44
|
+
if (activeProviders.length === 0) {
|
|
45
|
+
throw new Error('No providers configured. Add at least one provider API key.')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.log(`[JobRunner] Run ${runId}: dispatching to ${activeProviders.length} providers: ${activeProviders.map(p => p.adapter.name).join(', ')}`)
|
|
49
|
+
|
|
50
|
+
// Fetch keywords for the project
|
|
51
|
+
const projectKeywords = this.db
|
|
52
|
+
.select()
|
|
53
|
+
.from(keywords)
|
|
54
|
+
.where(eq(keywords.projectId, projectId))
|
|
55
|
+
.all()
|
|
56
|
+
|
|
57
|
+
// Fetch competitors for the project
|
|
58
|
+
const projectCompetitors = this.db
|
|
59
|
+
.select()
|
|
60
|
+
.from(competitors)
|
|
61
|
+
.where(eq(competitors.projectId, projectId))
|
|
62
|
+
.all()
|
|
63
|
+
|
|
64
|
+
const competitorDomains = projectCompetitors.map(c => c.domain)
|
|
65
|
+
|
|
66
|
+
// Enforce daily quota per provider — each provider receives one query per keyword.
|
|
67
|
+
// Track and check usage per (projectId, providerName) so that a provider that has
|
|
68
|
+
// never been used isn't blocked by another provider's past usage.
|
|
69
|
+
const queriesPerProvider = projectKeywords.length
|
|
70
|
+
const todayPeriod = getCurrentPeriod()
|
|
71
|
+
|
|
72
|
+
for (const p of activeProviders) {
|
|
73
|
+
const providerScope = `${projectId}:${p.adapter.name}`
|
|
74
|
+
const providerUsage = this.db
|
|
75
|
+
.select()
|
|
76
|
+
.from(usageCounters)
|
|
77
|
+
.where(eq(usageCounters.scope, providerScope))
|
|
78
|
+
.all()
|
|
79
|
+
.filter(r => r.period === todayPeriod && r.metric === 'queries')
|
|
80
|
+
.reduce((sum, r) => sum + r.count, 0)
|
|
81
|
+
const limit = p.config.quotaPolicy.maxRequestsPerDay
|
|
82
|
+
if (providerUsage + queriesPerProvider > limit) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Daily quota exceeded for ${p.adapter.name}: ${providerUsage} queries used today, ` +
|
|
85
|
+
`limit is ${limit}. This run needs ${queriesPerProvider} more.`,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Per-provider rate limiting: separate sliding windows
|
|
91
|
+
const minuteWindows = new Map<ProviderName, number[]>()
|
|
92
|
+
for (const p of activeProviders) {
|
|
93
|
+
minuteWindows.set(p.adapter.name, [])
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Track per-provider errors for partial completion
|
|
97
|
+
const providerErrors = new Map<ProviderName, string>()
|
|
98
|
+
let totalSnapshotsInserted = 0
|
|
99
|
+
|
|
100
|
+
// Process each keyword across all providers
|
|
101
|
+
for (const kw of projectKeywords) {
|
|
102
|
+
// Fan out across providers for this keyword
|
|
103
|
+
const providerPromises = activeProviders.map(async (registeredProvider) => {
|
|
104
|
+
const { adapter, config } = registeredProvider
|
|
105
|
+
const providerName = adapter.name
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
// Enforce per-minute rate limit
|
|
109
|
+
await this.waitForRateLimit(
|
|
110
|
+
minuteWindows.get(providerName)!,
|
|
111
|
+
config.quotaPolicy.maxRequestsPerMinute,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const raw = await adapter.executeTrackedQuery(
|
|
115
|
+
{
|
|
116
|
+
keyword: kw.keyword,
|
|
117
|
+
canonicalDomains: [project.canonicalDomain],
|
|
118
|
+
competitorDomains,
|
|
119
|
+
},
|
|
120
|
+
config,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const normalized = adapter.normalizeResult(raw)
|
|
124
|
+
|
|
125
|
+
console.log(`[JobRunner] ${providerName}: "${kw.keyword}" citedDomains=${JSON.stringify(normalized.citedDomains)}, groundingSources=${JSON.stringify(normalized.groundingSources.map(s => s.uri))}, canonical="${project.canonicalDomain}"`)
|
|
126
|
+
const citationState = determineCitationState(normalized, project.canonicalDomain)
|
|
127
|
+
const overlap = computeCompetitorOverlap(normalized, competitorDomains)
|
|
128
|
+
|
|
129
|
+
this.db.insert(querySnapshots).values({
|
|
130
|
+
id: crypto.randomUUID(),
|
|
131
|
+
runId,
|
|
132
|
+
keywordId: kw.id,
|
|
133
|
+
provider: providerName,
|
|
134
|
+
citationState,
|
|
135
|
+
answerText: normalized.answerText,
|
|
136
|
+
citedDomains: JSON.stringify(normalized.citedDomains),
|
|
137
|
+
competitorOverlap: JSON.stringify(overlap),
|
|
138
|
+
rawResponse: JSON.stringify({
|
|
139
|
+
model: raw.model,
|
|
140
|
+
groundingSources: normalized.groundingSources,
|
|
141
|
+
searchQueries: normalized.searchQueries,
|
|
142
|
+
apiResponse: raw.rawResponse,
|
|
143
|
+
}),
|
|
144
|
+
createdAt: new Date().toISOString(),
|
|
145
|
+
}).run()
|
|
146
|
+
|
|
147
|
+
totalSnapshotsInserted++
|
|
148
|
+
console.log(`[JobRunner] ${providerName}: keyword "${kw.keyword}" → ${citationState}`)
|
|
149
|
+
} catch (err: unknown) {
|
|
150
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
151
|
+
console.error(`[JobRunner] ${providerName}: keyword "${kw.keyword}" FAILED: ${msg}`)
|
|
152
|
+
providerErrors.set(providerName, msg)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
await Promise.all(providerPromises)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Determine final run status
|
|
160
|
+
const allFailed = totalSnapshotsInserted === 0 && providerErrors.size > 0
|
|
161
|
+
const someFailed = providerErrors.size > 0
|
|
162
|
+
|
|
163
|
+
if (allFailed) {
|
|
164
|
+
const errorDetail = JSON.stringify(Object.fromEntries(providerErrors))
|
|
165
|
+
this.db
|
|
166
|
+
.update(runs)
|
|
167
|
+
.set({ status: 'failed', finishedAt: new Date().toISOString(), error: errorDetail })
|
|
168
|
+
.where(eq(runs.id, runId))
|
|
169
|
+
.run()
|
|
170
|
+
} else if (someFailed) {
|
|
171
|
+
const errorDetail = JSON.stringify(Object.fromEntries(providerErrors))
|
|
172
|
+
this.db
|
|
173
|
+
.update(runs)
|
|
174
|
+
.set({ status: 'partial', finishedAt: new Date().toISOString(), error: errorDetail })
|
|
175
|
+
.where(eq(runs.id, runId))
|
|
176
|
+
.run()
|
|
177
|
+
} else {
|
|
178
|
+
this.db
|
|
179
|
+
.update(runs)
|
|
180
|
+
.set({ status: 'completed', finishedAt: new Date().toISOString() })
|
|
181
|
+
.where(eq(runs.id, runId))
|
|
182
|
+
.run()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Increment per-provider usage counters to keep quota checks accurate
|
|
186
|
+
for (const p of activeProviders) {
|
|
187
|
+
this.incrementUsage(`${projectId}:${p.adapter.name}`, 'queries', queriesPerProvider)
|
|
188
|
+
}
|
|
189
|
+
this.incrementUsage(projectId, 'runs', 1)
|
|
190
|
+
|
|
191
|
+
// Notify after run completion
|
|
192
|
+
if (this.onRunCompleted) {
|
|
193
|
+
this.onRunCompleted(runId, projectId).catch((err: unknown) => {
|
|
194
|
+
console.error('[JobRunner] Notification callback failed:', err)
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
} catch (err: unknown) {
|
|
198
|
+
// Mark run as failed
|
|
199
|
+
const errorMessage = err instanceof Error ? err.message : String(err)
|
|
200
|
+
this.db
|
|
201
|
+
.update(runs)
|
|
202
|
+
.set({
|
|
203
|
+
status: 'failed',
|
|
204
|
+
finishedAt: new Date().toISOString(),
|
|
205
|
+
error: errorMessage,
|
|
206
|
+
})
|
|
207
|
+
.where(eq(runs.id, runId))
|
|
208
|
+
.run()
|
|
209
|
+
|
|
210
|
+
// Notify on failure too
|
|
211
|
+
if (this.onRunCompleted) {
|
|
212
|
+
this.onRunCompleted(runId, projectId).catch((notifErr: unknown) => {
|
|
213
|
+
console.error('[JobRunner] Notification callback failed:', notifErr)
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private async waitForRateLimit(window: number[], maxPerMinute: number): Promise<void> {
|
|
220
|
+
const now = Date.now()
|
|
221
|
+
const windowStart = now - 60_000
|
|
222
|
+
while (window.length > 0 && window[0]! < windowStart) {
|
|
223
|
+
window.shift()
|
|
224
|
+
}
|
|
225
|
+
if (window.length >= maxPerMinute) {
|
|
226
|
+
const oldestInWindow = window[0]!
|
|
227
|
+
const waitMs = oldestInWindow + 60_000 - now + 50
|
|
228
|
+
await new Promise(resolve => setTimeout(resolve, waitMs))
|
|
229
|
+
const nowAfterWait = Date.now()
|
|
230
|
+
const newWindowStart = nowAfterWait - 60_000
|
|
231
|
+
while (window.length > 0 && window[0]! < newWindowStart) {
|
|
232
|
+
window.shift()
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
window.push(Date.now())
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private incrementUsage(scope: string, metric: string, count: number): void {
|
|
239
|
+
const now = new Date()
|
|
240
|
+
const period = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}`
|
|
241
|
+
const id = crypto.randomUUID()
|
|
242
|
+
|
|
243
|
+
const existing = this.db
|
|
244
|
+
.select()
|
|
245
|
+
.from(usageCounters)
|
|
246
|
+
.where(eq(usageCounters.scope, scope))
|
|
247
|
+
.all()
|
|
248
|
+
.find(r => r.period === period && r.metric === metric)
|
|
249
|
+
|
|
250
|
+
if (existing) {
|
|
251
|
+
this.db
|
|
252
|
+
.update(usageCounters)
|
|
253
|
+
.set({ count: existing.count + count, updatedAt: now.toISOString() })
|
|
254
|
+
.where(eq(usageCounters.id, existing.id))
|
|
255
|
+
.run()
|
|
256
|
+
} else {
|
|
257
|
+
this.db.insert(usageCounters).values({
|
|
258
|
+
id,
|
|
259
|
+
scope,
|
|
260
|
+
period,
|
|
261
|
+
metric,
|
|
262
|
+
count,
|
|
263
|
+
updatedAt: now.toISOString(),
|
|
264
|
+
}).run()
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function getCurrentPeriod(): string {
|
|
270
|
+
const d = new Date()
|
|
271
|
+
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Normalize a canonical domain that may be stored as a full URL (e.g. "https://www.ainyc.ai") to a bare domain ("ainyc.ai") */
|
|
275
|
+
function normalizeDomain(input: string): string {
|
|
276
|
+
let domain = input
|
|
277
|
+
try {
|
|
278
|
+
if (domain.includes('://')) {
|
|
279
|
+
domain = new URL(domain).hostname
|
|
280
|
+
}
|
|
281
|
+
} catch {
|
|
282
|
+
// not a URL, use as-is
|
|
283
|
+
}
|
|
284
|
+
return domain.replace(/^www\./, '')
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function domainMatches(domain: string, canonicalDomain: string): boolean {
|
|
288
|
+
const normalized = normalizeDomain(canonicalDomain)
|
|
289
|
+
const d = normalizeDomain(domain)
|
|
290
|
+
return d === normalized || d.endsWith(`.${normalized}`)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function determineCitationState(
|
|
294
|
+
normalized: NormalizedQueryResult,
|
|
295
|
+
canonicalDomain: string,
|
|
296
|
+
): 'cited' | 'not-cited' {
|
|
297
|
+
const bareDomain = normalizeDomain(canonicalDomain)
|
|
298
|
+
|
|
299
|
+
// Check extracted cited domains
|
|
300
|
+
if (normalized.citedDomains.some(d => domainMatches(d, bareDomain))) {
|
|
301
|
+
return 'cited'
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Also check grounding source URIs and titles directly
|
|
305
|
+
const lowerDomain = bareDomain.toLowerCase()
|
|
306
|
+
for (const source of normalized.groundingSources) {
|
|
307
|
+
try {
|
|
308
|
+
const uri = source.uri.toLowerCase()
|
|
309
|
+
if (uri.includes(lowerDomain)) {
|
|
310
|
+
return 'cited'
|
|
311
|
+
}
|
|
312
|
+
} catch {
|
|
313
|
+
// ignore
|
|
314
|
+
}
|
|
315
|
+
// Gemini proxy URLs use base64 encoding, so the domain won't appear in the URI.
|
|
316
|
+
// The title field often contains the bare domain (e.g. "ainyc.ai").
|
|
317
|
+
if (source.title) {
|
|
318
|
+
const titleLower = source.title.toLowerCase().replace(/^www\./, '')
|
|
319
|
+
if (titleLower === lowerDomain || titleLower.endsWith(`.${lowerDomain}`)) {
|
|
320
|
+
return 'cited'
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return 'not-cited'
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function computeCompetitorOverlap(
|
|
329
|
+
normalized: NormalizedQueryResult,
|
|
330
|
+
competitorDomains: string[],
|
|
331
|
+
): string[] {
|
|
332
|
+
const overlapSet = new Set<string>()
|
|
333
|
+
|
|
334
|
+
// Check extracted cited domains
|
|
335
|
+
for (const d of normalized.citedDomains) {
|
|
336
|
+
for (const cd of competitorDomains) {
|
|
337
|
+
if (domainMatches(d, cd)) {
|
|
338
|
+
overlapSet.add(cd)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Check grounding source URIs (handles proxy URLs)
|
|
344
|
+
for (const source of normalized.groundingSources) {
|
|
345
|
+
const uri = source.uri.toLowerCase()
|
|
346
|
+
for (const cd of competitorDomains) {
|
|
347
|
+
if (uri.includes(cd.toLowerCase())) {
|
|
348
|
+
overlapSet.add(cd)
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Check answer text for competitor domain mentions
|
|
354
|
+
if (normalized.answerText) {
|
|
355
|
+
const lowerAnswer = normalized.answerText.toLowerCase()
|
|
356
|
+
for (const cd of competitorDomains) {
|
|
357
|
+
if (lowerAnswer.includes(cd.toLowerCase())) {
|
|
358
|
+
overlapSet.add(cd)
|
|
359
|
+
}
|
|
360
|
+
const brand = cd.split('.')[0]
|
|
361
|
+
if (brand && brand.length >= 4 && new RegExp(`\\b${brand}\\b`, 'i').test(lowerAnswer)) {
|
|
362
|
+
overlapSet.add(cd)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return [...overlapSet]
|
|
368
|
+
}
|