@hzlmy2002/web-market 0.1.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.
@@ -0,0 +1,216 @@
1
+ import { z } from 'zod';
2
+ export declare const domain: z.ZodEffects<z.ZodString, string, string>;
3
+ export declare const month: z.ZodString;
4
+ export declare const domains: z.ZodEffects<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">, string[], string[]>;
5
+ export declare const inputs: {
6
+ AIsa_similar_sites: z.ZodObject<{
7
+ country: z.ZodDefault<z.ZodEnum<["us", "ww"]>>;
8
+ max_price_usd: z.ZodOptional<z.ZodNumber>;
9
+ domain: z.ZodEffects<z.ZodString, string, string>;
10
+ end_month: z.ZodString;
11
+ limit: z.ZodDefault<z.ZodNumber>;
12
+ }, "strict", z.ZodTypeAny, {
13
+ domain: string;
14
+ end_month: string;
15
+ limit: number;
16
+ country: "us" | "ww";
17
+ max_price_usd?: number | undefined;
18
+ }, {
19
+ domain: string;
20
+ end_month: string;
21
+ limit?: number | undefined;
22
+ country?: "us" | "ww" | undefined;
23
+ max_price_usd?: number | undefined;
24
+ }>;
25
+ AIsa_traffic_engagement: z.ZodObject<{
26
+ country: z.ZodDefault<z.ZodEnum<["us", "ww"]>>;
27
+ max_price_usd: z.ZodOptional<z.ZodNumber>;
28
+ domains: z.ZodEffects<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">, string[], string[]>;
29
+ start_month: z.ZodString;
30
+ end_month: z.ZodString;
31
+ }, "strict", z.ZodTypeAny, {
32
+ end_month: string;
33
+ country: "us" | "ww";
34
+ domains: string[];
35
+ start_month: string;
36
+ max_price_usd?: number | undefined;
37
+ }, {
38
+ end_month: string;
39
+ domains: string[];
40
+ start_month: string;
41
+ country?: "us" | "ww" | undefined;
42
+ max_price_usd?: number | undefined;
43
+ }>;
44
+ AIsa_geography: z.ZodObject<{
45
+ domains: z.ZodEffects<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">, string[], string[]>;
46
+ max_price_usd: z.ZodOptional<z.ZodNumber>;
47
+ }, "strict", z.ZodTypeAny, {
48
+ domains: string[];
49
+ max_price_usd?: number | undefined;
50
+ }, {
51
+ domains: string[];
52
+ max_price_usd?: number | undefined;
53
+ }>;
54
+ AIsa_website_keywords: z.ZodObject<{
55
+ country: z.ZodDefault<z.ZodEnum<["us", "ww"]>>;
56
+ max_price_usd: z.ZodOptional<z.ZodNumber>;
57
+ domain: z.ZodEffects<z.ZodString, string, string>;
58
+ month: z.ZodString;
59
+ limit: z.ZodDefault<z.ZodNumber>;
60
+ }, "strict", z.ZodTypeAny, {
61
+ domain: string;
62
+ limit: number;
63
+ country: "us" | "ww";
64
+ month: string;
65
+ max_price_usd?: number | undefined;
66
+ }, {
67
+ domain: string;
68
+ month: string;
69
+ limit?: number | undefined;
70
+ country?: "us" | "ww" | undefined;
71
+ max_price_usd?: number | undefined;
72
+ }>;
73
+ AIsa_keyword_gap: z.ZodObject<{
74
+ country: z.ZodDefault<z.ZodEnum<["us", "ww"]>>;
75
+ max_price_usd: z.ZodOptional<z.ZodNumber>;
76
+ target_domain: z.ZodEffects<z.ZodString, string, string>;
77
+ competitor_domains: z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">;
78
+ month: z.ZodString;
79
+ limit_per_domain: z.ZodDefault<z.ZodNumber>;
80
+ }, "strict", z.ZodTypeAny, {
81
+ country: "us" | "ww";
82
+ month: string;
83
+ target_domain: string;
84
+ competitor_domains: string[];
85
+ limit_per_domain: number;
86
+ max_price_usd?: number | undefined;
87
+ }, {
88
+ month: string;
89
+ target_domain: string;
90
+ competitor_domains: string[];
91
+ country?: "us" | "ww" | undefined;
92
+ max_price_usd?: number | undefined;
93
+ limit_per_domain?: number | undefined;
94
+ }>;
95
+ };
96
+ export type ToolName = keyof typeof inputs;
97
+ export declare const output: z.ZodObject<{
98
+ schema_version: z.ZodLiteral<"1.0.0">;
99
+ scope: z.ZodRecord<z.ZodString, z.ZodUnknown>;
100
+ data: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>, "many">;
101
+ derived: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>, "many">;
102
+ completeness: z.ZodObject<{
103
+ status: z.ZodEnum<["complete", "partial", "empty"]>;
104
+ requested_items: z.ZodArray<z.ZodString, "many">;
105
+ returned_items: z.ZodArray<z.ZodString, "many">;
106
+ failures: z.ZodArray<z.ZodObject<{
107
+ domain: z.ZodString;
108
+ code: z.ZodString;
109
+ message: z.ZodString;
110
+ }, "strip", z.ZodTypeAny, {
111
+ code: string;
112
+ message: string;
113
+ domain: string;
114
+ }, {
115
+ code: string;
116
+ message: string;
117
+ domain: string;
118
+ }>, "many">;
119
+ }, "strip", z.ZodTypeAny, {
120
+ status: "complete" | "partial" | "empty";
121
+ requested_items: string[];
122
+ returned_items: string[];
123
+ failures: {
124
+ code: string;
125
+ message: string;
126
+ domain: string;
127
+ }[];
128
+ }, {
129
+ status: "complete" | "partial" | "empty";
130
+ requested_items: string[];
131
+ returned_items: string[];
132
+ failures: {
133
+ code: string;
134
+ message: string;
135
+ domain: string;
136
+ }[];
137
+ }>;
138
+ provenance: z.ZodArray<z.ZodObject<{
139
+ domain: z.ZodString;
140
+ endpoint: z.ZodString;
141
+ retrieved_at: z.ZodString;
142
+ data_as_of: z.ZodNullable<z.ZodString>;
143
+ reported_scope: z.ZodRecord<z.ZodString, z.ZodUnknown>;
144
+ status: z.ZodEnum<["success", "empty", "failed"]>;
145
+ }, "strip", z.ZodTypeAny, {
146
+ status: "success" | "empty" | "failed";
147
+ domain: string;
148
+ endpoint: string;
149
+ retrieved_at: string;
150
+ data_as_of: string | null;
151
+ reported_scope: Record<string, unknown>;
152
+ }, {
153
+ status: "success" | "empty" | "failed";
154
+ domain: string;
155
+ endpoint: string;
156
+ retrieved_at: string;
157
+ data_as_of: string | null;
158
+ reported_scope: Record<string, unknown>;
159
+ }>, "many">;
160
+ calculation: z.ZodRecord<z.ZodString, z.ZodUnknown>;
161
+ warnings: z.ZodArray<z.ZodString, "many">;
162
+ }, "strip", z.ZodTypeAny, {
163
+ data: Record<string, unknown>[];
164
+ schema_version: "1.0.0";
165
+ scope: Record<string, unknown>;
166
+ derived: Record<string, unknown>[];
167
+ completeness: {
168
+ status: "complete" | "partial" | "empty";
169
+ requested_items: string[];
170
+ returned_items: string[];
171
+ failures: {
172
+ code: string;
173
+ message: string;
174
+ domain: string;
175
+ }[];
176
+ };
177
+ provenance: {
178
+ status: "success" | "empty" | "failed";
179
+ domain: string;
180
+ endpoint: string;
181
+ retrieved_at: string;
182
+ data_as_of: string | null;
183
+ reported_scope: Record<string, unknown>;
184
+ }[];
185
+ calculation: Record<string, unknown>;
186
+ warnings: string[];
187
+ }, {
188
+ data: Record<string, unknown>[];
189
+ schema_version: "1.0.0";
190
+ scope: Record<string, unknown>;
191
+ derived: Record<string, unknown>[];
192
+ completeness: {
193
+ status: "complete" | "partial" | "empty";
194
+ requested_items: string[];
195
+ returned_items: string[];
196
+ failures: {
197
+ code: string;
198
+ message: string;
199
+ domain: string;
200
+ }[];
201
+ };
202
+ provenance: {
203
+ status: "success" | "empty" | "failed";
204
+ domain: string;
205
+ endpoint: string;
206
+ retrieved_at: string;
207
+ data_as_of: string | null;
208
+ reported_scope: Record<string, unknown>;
209
+ }[];
210
+ calculation: Record<string, unknown>;
211
+ warnings: string[];
212
+ }>;
213
+ export type Result = z.infer<typeof output>;
214
+ export declare function monthIndex(m: string): number;
215
+ export declare function fromIndex(i: number): string;
216
+ export declare function windowMonths(start: string, end: string): string[];
package/dist/schema.js ADDED
@@ -0,0 +1,37 @@
1
+ import { z } from 'zod';
2
+ import { isIP } from 'node:net';
3
+ export const domain = z.string().max(2048).transform((input, ctx) => {
4
+ try {
5
+ const u = new URL(input.includes('://') ? input : `https://${input}`);
6
+ const d = u.hostname.toLowerCase().replace(/\.$/, '');
7
+ if (!['http:', 'https:'].includes(u.protocol) || u.username || u.password || u.port || isIP(d) || d.startsWith('[') || !d.includes('.') || /\.(localhost|local|internal|test|invalid)$/.test(d) || d.length > 253 || !d.split('.').every(s => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(s)))
8
+ throw Error();
9
+ return d;
10
+ }
11
+ catch {
12
+ ctx.addIssue({ code: 'custom', message: 'Provide a public website domain or HTTP(S) URL without credentials or port.' });
13
+ return z.NEVER;
14
+ }
15
+ });
16
+ export const month = z.string().regex(/^20\d{2}-(0[1-9]|1[0-2])$/);
17
+ export const domains = z.array(domain).min(1).max(5).refine(a => new Set(a).size === a.length, 'Domains must be unique after normalization.');
18
+ const country = z.enum(['us', 'ww']).default('ww');
19
+ const cap = z.number().finite().positive().max(1_000_000).optional().describe('Optional TOTAL USD ceiling for this tool invocation, split across its planned API calls.');
20
+ const common = { country, max_price_usd: cap };
21
+ export const inputs = {
22
+ AIsa_similar_sites: z.object({ domain, end_month: month.describe('Latest supported month for Similar Sites. Requests exactly this month and the two preceding months.'), limit: z.number().int().min(1).max(20).default(10), ...common }).strict(),
23
+ AIsa_traffic_engagement: z.object({ domains, start_month: month, end_month: month, ...common }).strict(),
24
+ AIsa_geography: z.object({ domains, max_price_usd: cap }).strict(),
25
+ AIsa_website_keywords: z.object({ domain, month, limit: z.number().int().min(1).max(20).default(20), ...common }).strict(),
26
+ AIsa_keyword_gap: z.object({ target_domain: domain, competitor_domains: z.array(domain).min(1).max(3), month, limit_per_domain: z.number().int().min(1).max(20).default(20), ...common }).strict(),
27
+ };
28
+ export const output = z.object({
29
+ schema_version: z.literal('1.0.0'), scope: z.record(z.unknown()),
30
+ data: z.array(z.record(z.unknown())), derived: z.array(z.record(z.unknown())),
31
+ completeness: z.object({ status: z.enum(['complete', 'partial', 'empty']), requested_items: z.array(z.string()), returned_items: z.array(z.string()), failures: z.array(z.object({ domain: z.string(), code: z.string(), message: z.string() })) }),
32
+ provenance: z.array(z.object({ domain: z.string(), endpoint: z.string(), retrieved_at: z.string(), data_as_of: z.string().nullable(), reported_scope: z.record(z.unknown()), status: z.enum(['success', 'empty', 'failed']) })),
33
+ calculation: z.record(z.unknown()), warnings: z.array(z.string()),
34
+ });
35
+ export function monthIndex(m) { const [y, n] = m.split('-').map(Number); return y * 12 + n - 1; }
36
+ export function fromIndex(i) { return `${Math.floor(i / 12)}-${String(i % 12 + 1).padStart(2, '0')}`; }
37
+ export function windowMonths(start, end) { return Array.from({ length: monthIndex(end) - monthIndex(start) + 1 }, (_, i) => fromIndex(monthIndex(start) + i)); }
@@ -0,0 +1,3 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { type Transport } from './api.js';
3
+ export declare function createServer(api?: Transport): McpServer;
package/dist/server.js ADDED
@@ -0,0 +1,20 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { ApiClient, safeError } from './api.js';
3
+ import { inputs, output } from './schema.js';
4
+ import { execute, descriptions } from './tools.js';
5
+ export function createServer(api = new ApiClient()) {
6
+ const server = new McpServer({ name: 'aisa-web-market', version: '0.1.0' }, { instructions: 'Website competitive analysis using five AIsa tools. Use the installed aisa-web-market skill for scenario guidance. Compare identical scope and preserve missing-data evidence. API calls are billed. Credentials come from AISA_API_KEY; never request the key in conversation.' });
7
+ for (const name of Object.keys(inputs)) {
8
+ server.registerTool(name, { description: descriptions[name], inputSchema: inputs[name], outputSchema: output.shape, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true } }, async (args, extra) => {
9
+ try {
10
+ const result = output.parse(await execute(name, args, api, extra.signal));
11
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
12
+ }
13
+ catch (e) {
14
+ const err = safeError(e);
15
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: { code: err.code, message: err.message, retryable: err.retryable, ...(err.status === undefined ? {} : { http_status: err.status }) } }) }] };
16
+ }
17
+ });
18
+ }
19
+ return server;
20
+ }
@@ -0,0 +1,4 @@
1
+ import { type Transport } from './api.js';
2
+ import { type ToolName, type Result } from './schema.js';
3
+ export declare const descriptions: Record<ToolName, string>;
4
+ export declare function execute(name: ToolName, raw: unknown, api: Transport, signal?: AbortSignal): Promise<Result>;
package/dist/tools.js ADDED
@@ -0,0 +1,182 @@
1
+ import { ApiError, paths, safeError } from './api.js';
2
+ import { inputs, monthIndex, fromIndex, windowMonths } from './schema.js';
3
+ export const descriptions = {
4
+ AIsa_similar_sites: 'Find similar website candidates for one domain. Returns domain, affinity and rank; similarity is not proof of a business competitor. Requires the latest supported end_month; fetches exactly three months. Up to 20 candidates. Use traffic_engagement for subsequent comparison. Billed AIsa API call.',
5
+ AIsa_traffic_engagement: 'Compare 1–5 domains over the SAME explicit monthly window (1–12 months), country and total web scope. Returns aligned visits, pages/visit, duration seconds, bounce-rate fractions, missing values and endpoint growth. Estimates, not first-party analytics. No cross-period averaging of rates. Billed once per domain.',
6
+ AIsa_geography: 'Fetch latest available worldwide traffic geography for 1–5 domains, at most 10 countries per domain. Returns country shares, visits and reported dates. Dates may differ between domains; no historical or country filter. Use only when geography is requested. Billed once per domain.',
7
+ AIsa_website_keywords: 'Read one website keyword sample for an explicit month, US or worldwide. At most 20 keywords with clicks, traffic share, position, intent and top URL when available. The API does not expose organic/nonbranded filters or volume/difficulty; do not infer them. For comparing sites use keyword_gap. Billed API call.',
8
+ AIsa_keyword_gap: 'Compare one target and 1–3 competitors using same-month, same-country keyword samples (max 20/site). Returns per-site evidence and deterministic position classes. competitor_only_in_sample means absent from a successful target SAMPLE, not no rankings. No organic/nonbranded filter, volume or difficulty guarantee. Does not generate content strategy. Billed once per domain.',
9
+ };
10
+ const numeric = (v) => typeof v === 'number' && Number.isFinite(v) ? v : null;
11
+ const text = (v) => typeof v === 'string' ? v : null;
12
+ function reportedScope(body) {
13
+ const source = body?.meta?.request ?? body?.meta ?? {};
14
+ return Object.fromEntries(['domain', 'country', 'start_date', 'end_date', 'granularity', 'web_source', 'main_domain_only', 'traffic_source', 'branded_type'].filter(k => ['string', 'boolean', 'number'].includes(typeof source[k])).map(k => [k, source[k]]));
15
+ }
16
+ function rows(body) {
17
+ if (!Array.isArray(body.data))
18
+ throw new ApiError('invalid_response', 'Expected an array of data rows.');
19
+ const flat = body.data.flat();
20
+ if (flat.some(x => !x || typeof x !== 'object' || Array.isArray(x)))
21
+ throw new ApiError('invalid_response', 'Invalid data row.');
22
+ return flat;
23
+ }
24
+ function keywords(body, limit) {
25
+ const seen = new Set();
26
+ return rows(body).slice(0, limit).map(r => {
27
+ if (typeof r.keyword !== 'string' || !r.keyword.trim())
28
+ throw new ApiError('invalid_response', 'Keyword row has no keyword.');
29
+ const key = r.keyword.normalize('NFKC').trim().toLowerCase();
30
+ if (seen.has(key))
31
+ throw new ApiError('invalid_response', 'Duplicate normalized keyword in sample.');
32
+ seen.add(key);
33
+ return { keyword: r.keyword, normalized_keyword: key, clicks: numeric(r.clicks), traffic_share: numeric(r.traffic_share), position: numeric(r.position), competition: numeric(r.competition), primary_intent: text(r.primary_intent), secondary_intent: text(r.secondary_intent), top_url: text(r.top_url) };
34
+ });
35
+ }
36
+ const metrics = { visits: 'visits', pages_per_visit: 'pages_per_visit', average_visit_duration_seconds: 'average_visit_duration', bounce_rate: 'bounce_rate' };
37
+ export async function execute(name, raw, api, signal) {
38
+ const parsed = inputs[name].safeParse(raw);
39
+ if (!parsed.success)
40
+ throw new ApiError('invalid_input', parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; '));
41
+ const a = parsed.data;
42
+ if (name === 'AIsa_traffic_engagement' && (monthIndex(a.end_month) < monthIndex(a.start_month) || monthIndex(a.end_month) - monthIndex(a.start_month) >= 12))
43
+ throw new ApiError('invalid_input', 'Traffic window must cover 1–12 consecutive months.');
44
+ const ds = a.domains ?? (a.target_domain ? [a.target_domain, ...a.competitor_domains] : [a.domain]);
45
+ if (new Set(ds).size !== ds.length)
46
+ throw new ApiError('invalid_input', 'Target and competitor domains must all be distinct.');
47
+ const endpoint = name === 'AIsa_traffic_engagement' ? paths.traffic : name === 'AIsa_similar_sites' ? paths.similar : name === 'AIsa_geography' ? paths.geography : paths.keywords;
48
+ const scope = { ...a };
49
+ delete scope.max_price_usd;
50
+ if (name === 'AIsa_geography')
51
+ Object.assign(scope, { country: 'ww', time: 'latest_available_per_domain' });
52
+ else
53
+ Object.assign(scope, { web_source: 'total', granularity: 'monthly' });
54
+ if (name === 'AIsa_similar_sites')
55
+ scope.start_month = fromIndex(monthIndex(a.end_month) - 2);
56
+ if (endpoint === paths.keywords)
57
+ Object.assign(scope, { traffic_source: 'upstream_default', branded_type: 'upstream_default', sample_limit: a.limit ?? a.limit_per_domain });
58
+ const result = { schema_version: '1.0.0', scope, data: [], derived: [], completeness: { status: 'complete', requested_items: ds, returned_items: [], failures: [] }, provenance: [], calculation: {}, warnings: [] };
59
+ // Divide the total ceiling into integer nanodollars, rounding DOWN. No retry can spend a second slice.
60
+ const perCallCap = a.max_price_usd === undefined ? undefined : Math.floor(a.max_price_usd * 1e9 / ds.length) / 1e9;
61
+ if (perCallCap !== undefined && perCallCap <= 0)
62
+ throw new ApiError('cost_limit_exceeded', 'Budget is too small to divide across the requested domains.');
63
+ const records = new Map();
64
+ const outcomes = new Array(ds.length);
65
+ let cursor = 0;
66
+ const runSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(60_000)]) : AbortSignal.timeout(60_000);
67
+ await Promise.all(Array.from({ length: Math.min(3, ds.length) }, async () => {
68
+ while (cursor < ds.length) {
69
+ const i = cursor++;
70
+ const d = ds[i];
71
+ const at = new Date().toISOString();
72
+ try {
73
+ runSignal.throwIfAborted();
74
+ let query = { domain: d, country: a.country, web_source: 'total', granularity: 'monthly' };
75
+ if (endpoint === paths.traffic)
76
+ Object.assign(query, { start_date: a.start_month, end_date: a.end_month, metrics: Object.values(metrics).join(','), main_domain_only: true });
77
+ else if (endpoint === paths.similar)
78
+ Object.assign(query, { start_date: scope.start_month, end_date: a.end_month, limit: a.limit });
79
+ else if (endpoint === paths.keywords)
80
+ Object.assign(query, { start_date: a.month, end_date: a.month, limit: a.limit ?? a.limit_per_domain });
81
+ else
82
+ query = { domain: d };
83
+ const body = await api.get(endpoint, query, perCallCap, runSignal);
84
+ const reported = body.meta?.request ?? body.meta ?? {};
85
+ if (endpoint !== paths.geography) {
86
+ for (const key of ['country', 'web_source', 'start_date', 'end_date']) {
87
+ if (typeof reported[key] === 'string' && query[key] !== undefined && (key.endsWith('date') ? reported[key].slice(0, 7) : reported[key]) !== query[key])
88
+ throw new ApiError('scope_mismatch', 'Upstream returned a different scope than requested.');
89
+ }
90
+ }
91
+ let data;
92
+ if (endpoint === paths.keywords)
93
+ data = keywords(body, a.limit ?? a.limit_per_domain);
94
+ else if (endpoint === paths.geography) {
95
+ if (!Array.isArray(body.data?.countries))
96
+ throw new ApiError('invalid_response', 'Expected a countries array.');
97
+ data = body.data.countries.slice(0, 10).map((r) => ({ country_code: text(r.country_code), country_name: text(r.country_name), share: numeric(r.share), visits: numeric(r.visits) }));
98
+ }
99
+ else if (endpoint === paths.similar)
100
+ data = rows(body).slice(0, a.limit).map(r => ({ domain: text(r.domain), affinity: numeric(r.affinity), rank: numeric(r.rank), category: text(r.category) }));
101
+ else {
102
+ const source = rows(body);
103
+ const byMonth = new Map();
104
+ for (const r of source) {
105
+ const m = typeof r.date === 'string' ? r.date.slice(0, 7) : '';
106
+ if (!/^20\d\d-(0[1-9]|1[0-2])$/.test(m) || byMonth.has(m) || m < a.start_month || m > a.end_month)
107
+ throw new ApiError('invalid_response', 'Traffic periods are missing, duplicated or outside the requested window.');
108
+ byMonth.set(m, r);
109
+ }
110
+ data = source.length ? windowMonths(a.start_month, a.end_month).map(period => ({ period, ...Object.fromEntries(Object.entries(metrics).map(([out, upstream]) => [out, numeric(byMonth.get(period)?.[upstream])])) })) : [];
111
+ }
112
+ outcomes[i] = { body, data, at };
113
+ }
114
+ catch (error) {
115
+ outcomes[i] = { error: runSignal.aborted ? new ApiError('cancelled', 'Tool deadline or cancellation interrupted the operation.') : safeError(error), at };
116
+ }
117
+ }
118
+ }));
119
+ for (let i = 0; i < ds.length; i++) {
120
+ const d = ds[i], o = outcomes[i];
121
+ result.provenance.push({ domain: d, endpoint, retrieved_at: o.at, data_as_of: text(o.body?.meta?.last_updated ?? o.body?.meta?.data_as_of), reported_scope: reportedScope(o.body), status: o.error ? 'failed' : o.data?.length ? 'success' : 'empty' });
122
+ if (o.body?.meta?.empty_reason === 'provider_no_data')
123
+ result.warnings.push(`${d}: Similarweb has no data for the requested scope. This is not an unavailable API or invalid credential.`);
124
+ if (o.error)
125
+ result.completeness.failures.push({ domain: d, code: o.error.code, message: o.error.message });
126
+ else {
127
+ records.set(d, o.data);
128
+ if (o.data.length)
129
+ result.completeness.returned_items.push(d);
130
+ if (name !== 'AIsa_keyword_gap')
131
+ result.data.push(...o.data.map(r => endpoint === paths.similar ? { seed_domain: d, ...r } : { domain: d, ...r }));
132
+ }
133
+ }
134
+ if (outcomes.every(o => o.error))
135
+ throw outcomes[0].error;
136
+ if (endpoint === paths.traffic) {
137
+ result.scope.main_domain_only = true;
138
+ for (const [d, rr] of records) {
139
+ if (!rr.length)
140
+ continue;
141
+ for (const metric of Object.keys(metrics)) {
142
+ const first = rr[0][metric], last = rr.at(-1)[metric];
143
+ const missing = rr.filter(r => r[metric] === null).map(r => r.period);
144
+ if (missing.length)
145
+ result.warnings.push(`${d}: ${metric} missing in ${missing.join(', ')}.`);
146
+ result.derived.push({ domain: d, metric, start_period: a.start_month, end_period: a.end_month, start_value: first, end_value: last, absolute_change: first === null || last === null ? null : last - first, relative_change: first === null || last === null || first === 0 ? null : (last - first) / first });
147
+ if (first === 0 || first === null)
148
+ result.warnings.push(`${d}: ${metric} growth is null because the baseline is zero or unknown.`);
149
+ }
150
+ }
151
+ result.calculation = { algorithm_version: 'traffic-derived-v1', relative_change: '(end_value-start_value)/start_value', rate_unit: 'fraction', duration_unit: 'seconds', aggregation: 'no cross-period averaging' };
152
+ }
153
+ if (endpoint === paths.keywords)
154
+ result.warnings.push('Top-N sample only; organic/nonbranded filters and volume/difficulty are not supported by the current AIsa contract. Competition is preserved as reported, not renamed to difficulty.');
155
+ if (name === 'AIsa_keyword_gap') {
156
+ const keys = new Set();
157
+ for (const d of a.competitor_domains)
158
+ for (const r of records.get(d) ?? [])
159
+ keys.add(r.normalized_keyword);
160
+ for (const key of [...keys].sort()) {
161
+ const evidence = ds.map(d => ({ domain: d, sample_status: records.has(d) ? (records.get(d).length ? 'returned' : 'empty') : 'failed', row: records.get(d)?.find(r => r.normalized_keyword === key) ?? null }));
162
+ const target = evidence[0].row;
163
+ const competitors = evidence.slice(1).flatMap(e => e.row ? [e.row] : []);
164
+ const positions = competitors.map(r => r.position).filter((p) => typeof p === 'number' && p > 0);
165
+ const best = positions.length ? Math.min(...positions) : null;
166
+ const delta = target && target.position > 0 && best !== null ? target.position - best : null;
167
+ // A failed or empty target response is not evidence of target absence.
168
+ const category = !records.get(a.target_domain)?.length ? 'insufficient_data' : !target ? 'competitor_only_in_sample' : delta === null ? 'insufficient_data' : delta >= 5 ? 'target_weaker' : delta <= -5 ? 'target_stronger' : 'similar_position';
169
+ result.data.push({ keyword: competitors[0].keyword, normalized_keyword: key, category, position_delta: delta, best_competitor_position: best, evidence, max_competitor_clicks: maxKnown(competitors.map(r => r.clicks)) });
170
+ }
171
+ const order = ['competitor_only_in_sample', 'target_weaker', 'similar_position', 'target_stronger', 'insufficient_data'];
172
+ result.data.sort((x, y) => order.indexOf(x.category) - order.indexOf(y.category) || (y.max_competitor_clicks ?? -1) - (x.max_competitor_clicks ?? -1) || (x.normalized_keyword < y.normalized_keyword ? -1 : x.normalized_keyword > y.normalized_keyword ? 1 : 0));
173
+ const targetRows = records.get(a.target_domain) ?? [];
174
+ result.calculation = { algorithm_version: 'keyword-gap-v1', position_threshold: 5, ranking: ['category', 'max_competitor_clicks_desc_null_last', 'normalized_keyword'], target_only_in_sample: targetRows.filter(r => !keys.has(r.normalized_keyword)).length };
175
+ }
176
+ if (endpoint === paths.geography)
177
+ result.warnings.push('Latest available per-domain snapshots may cover different months. Inspect provenance before comparing.');
178
+ const incomplete = result.completeness.failures.length > 0 || (result.completeness.returned_items.length > 0 && result.completeness.returned_items.length < ds.length) || (endpoint === paths.traffic && result.data.some(r => Object.keys(metrics).some(m => r[m] === null)));
179
+ result.completeness.status = incomplete ? 'partial' : result.data.length ? 'complete' : 'empty';
180
+ return result;
181
+ }
182
+ function maxKnown(values) { const ns = values.map(numeric).filter((n) => n !== null); return ns.length ? Math.max(...ns) : null; }
@@ -0,0 +1,25 @@
1
+ # Contract 1.0.0
2
+
3
+ The Node runtime calls https://api.aisa.one/apis/v1 using Authorization: Bearer from AISA_API_KEY. Credentials stay in the process environment. It does not call mcp.aisa.one. Upstream operation definitions and examples are pinned in upstream-snapshot.json; the docs checkout is source material, not runtime instructions.
4
+
5
+ | Public tool | API GET path | Maximum API requests |
6
+ |---|---|---|
7
+ | AIsa_similar_sites | /similarweb/website/similar-sites | 1 |
8
+ | AIsa_traffic_engagement | /similarweb/website/traffic-engagement | 5 |
9
+ | AIsa_geography | /similarweb/website-top-geographies | 5 |
10
+ | AIsa_website_keywords | /similarweb/search/website-keywords | 1 |
11
+ | AIsa_keyword_gap | /similarweb/search/website-keywords | 4 |
12
+
13
+ Traffic translates start_month/end_month to start_date/end_date; the runtime explicitly requests total web and main_domain_only=true and four metrics. The public window is limited to 12 buckets even though upstream allows 120. Similar Sites computes exactly three months ending at end_month. Keywords send the same month for start and end and flatten the documented grouped-array representation. Geography uses the product API's data.countries. Tool names are a separate public contract, not a renamed export of every upstream operation.
14
+
15
+ Outputs have schema_version, scope, data, derived, completeness, provenance, calculation and warnings. Data row shapes depend on the tool; the MCP output schema validates the common envelope. Traffic rows include period and four numeric-or-null metrics. Keyword evidence preserves original text and normalized matching key. Scope defaults and absent filters are explicit. Provenance copies only allowlisted scope fields, never arbitrary upstream metadata. Missing update timestamps are null. Empty and partial responses are successful MCP results; if every request fails, the tool returns isError and a stable error code in text content.
16
+
17
+ Traffic algorithm traffic-derived-v1 compares requested endpoints only, leaves growth null for zero/missing baselines and performs no cross-period averaging. Missing periods are inserted as null cells. Keyword-gap-v1 applies NFKC, trim and lowercase; it does not merge synonyms. Classification threshold is 5 positions. Target-only sample keywords are counted but not placed in the opportunity list. Missing/failed target samples never establish absence. Partial comparisons use only observed competitors and expose all failures.
18
+
19
+ Bearer, request headers and arbitrary upstream error bodies are not returned. 402 is payment_required because the gateway can use it for subscription or credit restrictions. No per-endpoint prices are hardcoded. max_price_usd is divided among planned calls; tiny budgets that round to zero fail before requests. A network interruption is not automatically replayed.
20
+
21
+ Remaining integration limits: no OAuth, no automatic publication-date discovery, no organic/nonbranded keyword filters, no live price estimation, no durable query cache, no interactive-client evals. See live-verification-2026-09-05.md for test-account evidence. HTTP bearer requests and sample parsing are verified using fixtures, including the docs' example envelopes.
22
+
23
+ 404 diagnostics: only the known gateway error `api endpoint not found` maps to unavailable_operation. Other 404 responses map to not_found without assuming route disablement, account permissions or missing domain data. Tool errors include http_status when known; arbitrary upstream messages are never forwarded. The aisa-mcp catalogue includes website-top-geographies but explicitly excludes website/traffic-geography and website/folders based on its 2026-08-26 production checks. This historical catalogue is not a live availability guarantee.
24
+
25
+ Live-confirmed Similarweb no-data signature: HTTP 404, meta.status=error, numeric meta.error_code=401, meta.error_message="Data not found", data=null. This exact signature becomes an empty result with a warning; unknown provider errors are not reclassified as empty.
@@ -0,0 +1,39 @@
1
+ # Live API verification — 2026-09-05
2
+
3
+ User-authorized test-account calls, no price cap. Key was passed through stdin, retained only in process memory, and is absent from artifacts. Scope: the five Node tools and their four REST dependencies; not the full AIsa catalogue.
4
+
5
+ ## Results
6
+
7
+ 9 HTTP requests: 7 HTTP 200, 2 HTTP 404. No automatic retries. Actual charges were not queried or independently verified. The snapshot product API was used once to establish the published month (2026-07).
8
+
9
+ | Tool | Scope | Result |
10
+ |---|---|---|
11
+ | AIsa_geography | estk.me + wikipedia.org, latest | estk.me: provider Data not found; wikipedia.org: 10 countries |
12
+ | AIsa_traffic_engagement | same domains, worldwide, 2026-07 | estk.me: provider Data not found; wikipedia.org: four metrics |
13
+ | AIsa_similar_sites | wikipedia.org, 2026-05 through 2026-07 | 5 candidates |
14
+ | AIsa_website_keywords | wikipedia.org, worldwide, 2026-07, limit 5 | 5 keywords |
15
+ | AIsa_keyword_gap | wikipedia.org vs britannica.com, same month/country, 5 per domain | 5 competitor-only-in-sample rows |
16
+
17
+ ## Root cause and fix
18
+
19
+ The geography endpoint is available for this test account. The failing domain returned HTTP 404 with `meta.status=error`, `meta.error_code=401`, `meta.error_message="Data not found"`, `data=null`. This provider code is not an HTTP 401 authentication failure. The control domain succeeded on the same endpoint. The same domain-specific no-data condition occurred on traffic. This establishes absence only for the requested dataset/scope, not that the site has no traffic.
20
+
21
+ The Node client now recognizes this exact error signature and produces an empty successful result with a no-data warning and the reported scope. Mixed-domain calls preserve usable results and report partial coverage. Unknown 404s remain not_found, and explicit gateway route errors remain unavailable_operation. No-data behavior is regression-tested by replaying the captured responses.
22
+
23
+ All fetched keyword positions were null. No ranking-delta comparison can be claimed from these samples. The returned traffic_source and branded_type were all; the samples cannot be described as organic/nonbranded-only.
24
+
25
+ ## Request trace
26
+
27
+ | Path and query | HTTP | Request ID |
28
+ |---|---|---|
29
+ | `/similarweb/website-top-geographies?domain=estk.me` | 404 | `imc_664ccfae120ba90a9cb90cdb3edaa878f2d2624b8f1afdab60e0c4c502557be9` |
30
+ | `/similarweb/website-top-geographies?domain=wikipedia.org` | 200 | `imc_31c75e88d94e357b961cdadab9c6c9881a55d3a2f226bbb5d07d580b2b1e0607` |
31
+ | `/similarweb/website-traffic-snapshot?domain=wikipedia.org` | 200 | `imc_6f123b083918352aab0b1256be492ab0941718fc2f21801f6673733680c04902` |
32
+ | `/similarweb/website/traffic-engagement?domain=wikipedia.org&country=ww&web_source=total&granularity=monthly&start_date=2026-07&end_date=2026-07&metrics=visits%2Cpages_per_visit%2Caverage_visit_duration%2Cbounce_rate&main_domain_only=true` | 200 | `imc_ac10c6761a978fa96ac426ede97c811c48946e642a1700157b3a10797044da93` |
33
+ | `/similarweb/website/traffic-engagement?domain=estk.me&country=ww&web_source=total&granularity=monthly&start_date=2026-07&end_date=2026-07&metrics=visits%2Cpages_per_visit%2Caverage_visit_duration%2Cbounce_rate&main_domain_only=true` | 404 | `imc_1168e3e4c205968b222d4e4fc2eb8dd882ff3ea0ec7d4cb78fd914564d36abfe` |
34
+ | `/similarweb/website/similar-sites?domain=wikipedia.org&country=ww&web_source=total&granularity=monthly&start_date=2026-05&end_date=2026-07&limit=5` | 200 | `imc_7c18c6db3bede7494232833473246d68f77a51be798b5082ea4802ed4b0e19c6` |
35
+ | `/similarweb/search/website-keywords?domain=wikipedia.org&country=ww&web_source=total&granularity=monthly&start_date=2026-07&end_date=2026-07&limit=5` | 200 | `imc_ce3260cd2bd334600c3d48c8fdc1b6239cc6b7f264a819279ea4d5a271acb55b` |
36
+ | `/similarweb/search/website-keywords?domain=wikipedia.org&country=ww&web_source=total&granularity=monthly&start_date=2026-07&end_date=2026-07&limit=5` | 200 | `imc_3c2aedb4d04bf4322b4dcea810194e08a14c395d6e31917fcbac011d2e33d3e6` |
37
+ | `/similarweb/search/website-keywords?domain=britannica.com&country=ww&web_source=total&granularity=monthly&start_date=2026-07&end_date=2026-07&limit=5` | 200 | `imc_ec88005bbec5ed0b919c30fcd8d203ff662ca5354e0a052a1e0bc3d55ba4d0ec` |
38
+
39
+ Full public-data response fixtures and pre-fix tool outputs are in `tests/fixtures/live-2026-09-05.json`. Updated behavior is verified in `tests/live-regression.test.mjs`. No interactive Codex/Claude/Hermes UI eval was performed in this run.