webmaster-mcp 0.1.0 → 0.1.1

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.
@@ -1,6 +1,6 @@
1
- import { normalizeError, WebmasterError } from './errors.js';
2
- import { validateSite, validateUrl, canonicalSite, range } from './validation.js';
3
1
  import { cacheKey } from './cache/index.js';
2
+ import { normalizeError, WebmasterError } from './errors.js';
3
+ import { canonicalSite, range, validateSite, validateUrl } from './validation.js';
4
4
  export class WebmasterService {
5
5
  providers;
6
6
  cache;
@@ -10,39 +10,119 @@ export class WebmasterService {
10
10
  this.cache = cache;
11
11
  this.providerFactory = providerFactory;
12
12
  }
13
- selected(engines, account) { return this.providers.filter(p => (!engines || engines.includes(p.id)) && (!account || p.account === account)); }
14
- oneAccount(rows, account) { if (account)
15
- return rows; const chosen = new Map(); for (const row of rows) {
16
- const current = chosen.get(row.engine);
17
- if (!current || row.account === 'default')
18
- chosen.set(row.engine, row.account ?? 'default');
19
- } return rows.filter(row => (row.account ?? 'default') === chosen.get(row.engine)); }
20
- async collect(capability, fn, engines, account) { if (this.providerFactory)
21
- this.providers.splice(0, this.providers.length, ...this.providerFactory()); const selected = this.selected(engines, account); const settled = await Promise.allSettled(selected.map(async (p) => { if (!p.supports(capability))
22
- throw new Error('UNSUPPORTED'); if (!await p.isAuthenticated()) {
23
- if (engines?.includes(p.id) || account)
24
- throw new WebmasterError('AUTH_FAILED', `${p.id}/${p.account} is not connected.`);
25
- return [];
26
- } return fn(p); })); const results = [], errors = []; settled.forEach((item, i) => { const p = selected[i]; if (item.status === 'fulfilled')
27
- results.push(...item.value);
28
- else
29
- errors.push(item.reason?.message === 'UNSUPPORTED' ? { engine: p.id, account: p.account, code: 'UNSUPPORTED', message: `${capability} is not supported by ${p.id}.` } : normalizeError(p.id, p.account, item.reason)); }); return { results, errors }; }
30
- async sites() { const data = await this.collect('sites', p => this.cached(p, '', 'sites', {}, 3600000, () => p.getSites())); const grouped = new Map(); for (const s of data.results) {
31
- const id = canonicalSite(s.site);
32
- const entry = grouped.get(id) ?? { site: id, engines: [], accounts: {} };
33
- if (!entry.engines.includes(s.engine))
34
- entry.engines.push(s.engine);
35
- entry.accounts[s.engine] = [...(entry.accounts[s.engine] ?? []), s.account];
36
- grouped.set(id, entry);
37
- } return { results: [...grouped.values()], errors: data.errors }; }
38
- async cached(p, site, operation, params, ttl, load) { const key = cacheKey(p.id, p.account, site, operation, params); const hit = this.cache.get(key); if (hit !== null)
39
- return hit; const value = await load(); this.cache.set(key, value, ttl); return value; }
40
- async metrics(operation, input, engines) { range.parse(input); validateSite(input.site); const id = canonicalSite(input.site); const data = await this.collect(operation, async (p) => { const sites = await this.cached(p, '', 'sites', {}, 3600000, () => p.getSites()); const owned = sites.find(s => canonicalSite(s.site) === id); if (!owned)
41
- throw new WebmasterError('PERMISSION_DENIED', 'Site is not available in this account.'); const providerInput = { ...input, site: owned.site }; const rows = await this.cached(p, owned.site, operation, providerInput, 21600000, () => p[operation === 'performance' ? 'getPerformance' : operation === 'pages' ? 'getPages' : 'getQueries'](providerInput)); return rows.map(row => ({ ...row, site: id })); }, engines, input.account); data.results = this.oneAccount(data.results, input.account); if (input.sort)
42
- data.results.sort((a, b) => (b[input.sort] ?? 0) - (a[input.sort] ?? 0)); if (input.limit)
43
- data.results = data.results.slice(0, input.limit); return data; }
44
- async sitemaps(site, engines, account) { validateSite(site); const id = canonicalSite(site); const data = await this.collect('sitemaps', async (p) => { const sites = await p.getSites(); const owned = sites.find(s => canonicalSite(s.site) === id); if (!owned)
45
- throw new WebmasterError('PERMISSION_DENIED', 'Site is not available in this account.'); const rows = await this.cached(p, owned.site, 'sitemaps', {}, 1800000, () => p.getSitemaps(owned.site)); return rows.map(row => ({ ...row, site: id, account: p.account })); }, engines, account); data.results = this.oneAccount(data.results, account); return data; }
46
- async inspect(site, url, engines, account) { validateUrl(site, url); const id = canonicalSite(site); const data = await this.collect('urlInspection', async (p) => { const sites = await p.getSites(); const owned = sites.find(s => canonicalSite(s.site) === id); if (!owned)
47
- throw new WebmasterError('PERMISSION_DENIED', 'Site is not available in this account.'); const row = await this.cached(p, owned.site, 'inspect_url', { url }, 300000, () => p.inspectUrl(owned.site, url)); return [{ ...row, site: id, account: p.account }]; }, engines, account); data.results = this.oneAccount(data.results, account); return data; }
13
+ selected(engines, account) {
14
+ return this.providers.filter((p) => (!engines || engines.includes(p.id)) && (!account || p.account === account));
15
+ }
16
+ oneAccount(rows, account) {
17
+ if (account)
18
+ return rows;
19
+ const chosen = new Map();
20
+ for (const row of rows) {
21
+ const current = chosen.get(row.engine);
22
+ if (!current || row.account === 'default')
23
+ chosen.set(row.engine, row.account ?? 'default');
24
+ }
25
+ return rows.filter((row) => (row.account ?? 'default') === chosen.get(row.engine));
26
+ }
27
+ async collect(capability, fn, engines, account) {
28
+ if (this.providerFactory)
29
+ this.providers.splice(0, this.providers.length, ...this.providerFactory());
30
+ const selected = this.selected(engines, account);
31
+ const settled = await Promise.allSettled(selected.map(async (p) => {
32
+ if (!p.supports(capability))
33
+ throw new Error('UNSUPPORTED');
34
+ if (!(await p.isAuthenticated())) {
35
+ if (engines?.includes(p.id) || account)
36
+ throw new WebmasterError('AUTH_FAILED', `${p.id}/${p.account} is not connected.`);
37
+ return [];
38
+ }
39
+ return fn(p);
40
+ }));
41
+ const results = [], errors = [];
42
+ settled.forEach((item, i) => {
43
+ const p = selected[i];
44
+ if (item.status === 'fulfilled')
45
+ results.push(...item.value);
46
+ else
47
+ errors.push(item.reason?.message === 'UNSUPPORTED'
48
+ ? {
49
+ engine: p.id,
50
+ account: p.account,
51
+ code: 'UNSUPPORTED',
52
+ message: `${capability} is not supported by ${p.id}.`,
53
+ }
54
+ : normalizeError(p.id, p.account, item.reason));
55
+ });
56
+ return { results, errors };
57
+ }
58
+ async sites() {
59
+ const data = await this.collect('sites', (p) => this.cached(p, '', 'sites', {}, 3600000, () => p.getSites()));
60
+ const grouped = new Map();
61
+ for (const s of data.results) {
62
+ const id = canonicalSite(s.site);
63
+ const entry = grouped.get(id) ?? { site: id, engines: [], accounts: {} };
64
+ if (!entry.engines.includes(s.engine))
65
+ entry.engines.push(s.engine);
66
+ entry.accounts[s.engine] = [...(entry.accounts[s.engine] ?? []), s.account];
67
+ grouped.set(id, entry);
68
+ }
69
+ return { results: [...grouped.values()], errors: data.errors };
70
+ }
71
+ async cached(p, site, operation, params, ttl, load) {
72
+ const key = cacheKey(p.id, p.account, site, operation, params);
73
+ const hit = this.cache.get(key);
74
+ if (hit !== null)
75
+ return hit;
76
+ const value = await load();
77
+ this.cache.set(key, value, ttl);
78
+ return value;
79
+ }
80
+ async metrics(operation, input, engines) {
81
+ range.parse(input);
82
+ validateSite(input.site);
83
+ const id = canonicalSite(input.site);
84
+ const data = await this.collect(operation, async (p) => {
85
+ const sites = await this.cached(p, '', 'sites', {}, 3600000, () => p.getSites());
86
+ const owned = sites.find((s) => canonicalSite(s.site) === id);
87
+ if (!owned)
88
+ throw new WebmasterError('PERMISSION_DENIED', 'Site is not available in this account.');
89
+ const providerInput = { ...input, site: owned.site };
90
+ const rows = await this.cached(p, owned.site, operation, providerInput, 21600000, () => p[operation === 'performance' ? 'getPerformance' : operation === 'pages' ? 'getPages' : 'getQueries'](providerInput));
91
+ return rows.map((row) => ({ ...row, site: id }));
92
+ }, engines, input.account);
93
+ data.results = this.oneAccount(data.results, input.account);
94
+ if (input.sort)
95
+ data.results.sort((a, b) => (b[input.sort] ?? 0) - (a[input.sort] ?? 0));
96
+ if (input.limit)
97
+ data.results = data.results.slice(0, input.limit);
98
+ return data;
99
+ }
100
+ async sitemaps(site, engines, account) {
101
+ validateSite(site);
102
+ const id = canonicalSite(site);
103
+ const data = await this.collect('sitemaps', async (p) => {
104
+ const sites = await p.getSites();
105
+ const owned = sites.find((s) => canonicalSite(s.site) === id);
106
+ if (!owned)
107
+ throw new WebmasterError('PERMISSION_DENIED', 'Site is not available in this account.');
108
+ const rows = await this.cached(p, owned.site, 'sitemaps', {}, 1800000, () => p.getSitemaps(owned.site));
109
+ return rows.map((row) => ({ ...row, site: id, account: p.account }));
110
+ }, engines, account);
111
+ data.results = this.oneAccount(data.results, account);
112
+ return data;
113
+ }
114
+ async inspect(site, url, engines, account) {
115
+ validateUrl(site, url);
116
+ const id = canonicalSite(site);
117
+ const data = await this.collect('urlInspection', async (p) => {
118
+ const sites = await p.getSites();
119
+ const owned = sites.find((s) => canonicalSite(s.site) === id);
120
+ if (!owned)
121
+ throw new WebmasterError('PERMISSION_DENIED', 'Site is not available in this account.');
122
+ const row = await this.cached(p, owned.site, 'inspect_url', { url }, 300000, () => p.inspectUrl(owned.site, url));
123
+ return [{ ...row, site: id, account: p.account }];
124
+ }, engines, account);
125
+ data.results = this.oneAccount(data.results, account);
126
+ return data;
127
+ }
48
128
  }
@@ -1,35 +1,56 @@
1
1
  import { z } from 'zod';
2
2
  import { WebmasterError } from './errors.js';
3
3
  export const date = z.iso.date();
4
- export const range = z.object({ site: z.string().min(1), from: date, to: date, account: z.string().optional(), limit: z.number().int().min(1).max(25000).optional(), sort: z.enum(['clicks', 'impressions', 'ctr', 'position']).optional() }).refine(x => x.from <= x.to, 'from must be before to');
5
- export function validateSite(site) { if (/^sc-domain:[a-z0-9.-]+$/i.test(site))
6
- return site; try {
4
+ export const range = z
5
+ .object({
6
+ site: z.string().min(1),
7
+ from: date,
8
+ to: date,
9
+ account: z.string().optional(),
10
+ limit: z.number().int().min(1).max(25000).optional(),
11
+ sort: z.enum(['clicks', 'impressions', 'ctr', 'position']).optional(),
12
+ })
13
+ .refine((x) => x.from <= x.to, 'from must be before to');
14
+ export function validateSite(site) {
15
+ if (/^sc-domain:[a-z0-9.-]+$/i.test(site))
16
+ return site;
17
+ try {
18
+ const u = new URL(site);
19
+ if (!['http:', 'https:'].includes(u.protocol) || !u.hostname || u.username || u.password || u.hash)
20
+ throw new Error();
21
+ return site;
22
+ }
23
+ catch {
24
+ throw new WebmasterError('INVALID_INPUT', 'Site must be an HTTP(S) property URL or sc-domain property.');
25
+ }
26
+ }
27
+ export function canonicalSite(site) {
28
+ validateSite(site);
29
+ if (site.startsWith('sc-domain:'))
30
+ return site.toLowerCase();
7
31
  const u = new URL(site);
8
- if (!['http:', 'https:'].includes(u.protocol) || !u.hostname || u.username || u.password || u.hash)
9
- throw new Error();
10
- return site;
32
+ u.hash = '';
33
+ return u.href;
11
34
  }
12
- catch {
13
- throw new WebmasterError('INVALID_INPUT', 'Site must be an HTTP(S) property URL or sc-domain property.');
14
- } }
15
- export function canonicalSite(site) { validateSite(site); if (site.startsWith('sc-domain:'))
16
- return site.toLowerCase(); const u = new URL(site); u.hash = ''; return u.href; }
17
- export function validateUrl(site, url) { validateSite(site); try {
18
- const target = new URL(url);
19
- if (!['http:', 'https:'].includes(target.protocol) || target.username || target.password || target.hash)
20
- throw new Error();
21
- if (site.startsWith('sc-domain:')) {
22
- const domain = site.slice(10);
23
- if (target.hostname !== domain && !target.hostname.endsWith(`.${domain}`))
35
+ export function validateUrl(site, url) {
36
+ validateSite(site);
37
+ try {
38
+ const target = new URL(url);
39
+ if (!['http:', 'https:'].includes(target.protocol) || target.username || target.password || target.hash)
24
40
  throw new Error();
41
+ if (site.startsWith('sc-domain:')) {
42
+ const domain = site.slice(10);
43
+ if (target.hostname !== domain && !target.hostname.endsWith(`.${domain}`))
44
+ throw new Error();
45
+ }
46
+ else {
47
+ const property = new URL(site);
48
+ if (target.origin !== property.origin || !target.pathname.startsWith(property.pathname))
49
+ throw new Error();
50
+ }
51
+ return target.href;
25
52
  }
26
- else {
27
- const property = new URL(site);
28
- if (target.origin !== property.origin || !target.pathname.startsWith(property.pathname))
29
- throw new Error();
53
+ catch {
54
+ throw new WebmasterError('INVALID_INPUT', 'URL must be HTTP(S) and belong to the selected site.');
30
55
  }
31
- return target.href;
32
56
  }
33
- catch {
34
- throw new WebmasterError('INVALID_INPUT', 'URL must be HTTP(S) and belong to the selected site.');
35
- } }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- export * from './core/types/index.js';
2
1
  export * from './core/analysis/index.js';
3
- export * from './core/service.js';
4
- export * from './core/cache/index.js';
5
2
  export * from './core/auth/store.js';
6
- export { GoogleProvider } from './providers/google/index.js';
3
+ export * from './core/cache/index.js';
4
+ export * from './core/service.js';
5
+ export * from './core/types/index.js';
7
6
  export { BingProvider } from './providers/bing/index.js';
7
+ export { GoogleProvider } from './providers/google/index.js';
8
8
  export { YandexProvider } from './providers/yandex/index.js';
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
- export * from './core/types/index.js';
2
1
  export * from './core/analysis/index.js';
3
- export * from './core/service.js';
4
- export * from './core/cache/index.js';
5
2
  export * from './core/auth/store.js';
6
- export { GoogleProvider } from './providers/google/index.js';
3
+ export * from './core/cache/index.js';
4
+ export * from './core/service.js';
5
+ export * from './core/types/index.js';
7
6
  export { BingProvider } from './providers/bing/index.js';
7
+ export { GoogleProvider } from './providers/google/index.js';
8
8
  export { YandexProvider } from './providers/yandex/index.js';
package/dist/mcp/index.js CHANGED
@@ -1,4 +1,15 @@
1
1
  import { createMcpServer } from 'mcponce';
2
2
  import { createContext } from '../server/context.js';
3
3
  import { registerTools } from './tools/index.js';
4
- export function createServer() { const { service } = createContext(); const app = createMcpServer({ name: 'webmaster-mcp', version: '0.1.0', background: process.env.MCP_BACKGROUND !== '0', coerceInputs: true, dataDir: process.env.WEBMASTER_MCP_DATA_DIR }); registerTools(app, service); return app; }
4
+ export function createServer() {
5
+ const { service } = createContext();
6
+ const app = createMcpServer({
7
+ name: 'webmaster-mcp',
8
+ version: '0.1.0',
9
+ background: process.env.MCP_BACKGROUND !== '0',
10
+ coerceInputs: true,
11
+ dataDir: process.env.WEBMASTER_MCP_DATA_DIR,
12
+ });
13
+ registerTools(app, service);
14
+ return app;
15
+ }
@@ -1,15 +1,96 @@
1
1
  import { z } from 'zod';
2
- import { compareEngines, findCtrOpportunities, findNearFirstPage, findDecliningPages, findDecliningQueries, findImpressionGrowthWithoutClicks, findEngineGaps, findIndexingGaps } from '../../core/analysis/index.js';
2
+ import { compareEngines, findCtrOpportunities, findDecliningPages, findDecliningQueries, findEngineGaps, findImpressionGrowthWithoutClicks, findIndexingGaps, findNearFirstPage, } from '../../core/analysis/index.js';
3
3
  const engines = z.array(z.enum(['google', 'bing', 'yandex'])).optional();
4
4
  const base = { site: z.string(), from: z.iso.date(), to: z.iso.date(), engines, account: z.string().optional() };
5
- function previousRange(from, to) { const days = (Date.parse(to) - Date.parse(from)) / 86400000 + 1; const end = new Date(Date.parse(from) - 86400000); const start = new Date(end.getTime() - (days - 1) * 86400000); return { from: start.toISOString().slice(0, 10), to: end.toISOString().slice(0, 10) }; }
5
+ function previousRange(from, to) {
6
+ const days = (Date.parse(to) - Date.parse(from)) / 86400000 + 1;
7
+ const end = new Date(Date.parse(from) - 86400000);
8
+ const start = new Date(end.getTime() - (days - 1) * 86400000);
9
+ return { from: start.toISOString().slice(0, 10), to: end.toISOString().slice(0, 10) };
10
+ }
6
11
  export function registerTools(app, service) {
7
- app.tool({ name: 'sites', description: 'List normalized sites across connected webmaster accounts.', handler: () => service.sites() });
12
+ app.tool({
13
+ name: 'sites',
14
+ description: 'List normalized sites across connected webmaster accounts.',
15
+ handler: () => service.sites(),
16
+ });
8
17
  for (const operation of ['performance', 'queries', 'pages'])
9
- app.tool({ name: operation, description: `Get normalized ${operation} metrics with partial provider errors.`, inputSchema: z.object({ ...base, limit: z.number().int().min(1).max(25000).optional(), sort: z.enum(['clicks', 'impressions', 'ctr', 'position']).optional() }), handler: (a) => service.metrics(operation, a, a.engines) });
10
- app.tool({ name: 'sitemaps', description: 'List submitted sitemaps for a verified site.', inputSchema: z.object({ site: z.string(), engines, account: z.string().optional() }), handler: (a) => service.sitemaps(a.site, a.engines, a.account) });
11
- app.tool({ name: 'inspect_url', description: 'Inspect URL indexing where provider API offers evidence; unknown means unverified.', inputSchema: z.object({ site: z.string(), url: z.url(), engines, account: z.string().optional() }), handler: (a) => service.inspect(a.site, a.url, a.engines, a.account) });
12
- app.tool({ name: 'compare_engines', description: 'Compare provider-reported metrics without equating their counting semantics.', inputSchema: z.object(base), handler: async (a) => { const data = await service.metrics('performance', a, a.engines); return { ...data, results: compareEngines(data.results) }; } });
13
- app.tool({ name: 'opportunities', description: 'Deterministic CTR, ranking and trend candidates with supporting metrics.', inputSchema: z.object({ ...base, minImpressions: z.number().min(0).default(100), maxCtr: z.number().min(0).max(1).default(0.02), declineFraction: z.number().min(0).max(1).default(0.2) }), handler: async (a) => { const prior = previousRange(a.from, a.to); const [queries, pages, oldQueries, oldPages] = await Promise.all([service.metrics('queries', a, a.engines), service.metrics('pages', a, a.engines), service.metrics('queries', { ...a, ...prior }, a.engines), service.metrics('pages', { ...a, ...prior }, a.engines)]); return { results: [...findCtrOpportunities(queries.results, { minImpressions: a.minImpressions, maxCtr: a.maxCtr }), ...findNearFirstPage(queries.results), ...findDecliningPages(pages.results, oldPages.results, a.declineFraction), ...findDecliningQueries(queries.results, oldQueries.results, a.declineFraction), ...findImpressionGrowthWithoutClicks(queries.results, oldQueries.results)], errors: [...queries.errors, ...pages.errors, ...oldQueries.errors, ...oldPages.errors] }; } });
14
- app.tool({ name: 'indexing_gaps', description: 'Find verified indexing differences for supplied URLs and search visibility gaps. Missing performance rows are not indexing proof.', inputSchema: z.object({ ...base, urls: z.array(z.url()).max(20).optional() }), handler: async (a) => { const pageData = await service.metrics('pages', a, a.engines); const selected = (a.engines ?? ['google', 'bing', 'yandex']).filter(engine => !pageData.errors.some(e => e.engine === engine)); const inspected = await Promise.all((a.urls ?? []).map((url) => service.inspect(a.site, url, a.engines, a.account))); return { results: { visibilityGaps: findEngineGaps(pageData.results, selected), verifiedIndexingGaps: findIndexingGaps(inspected.flatMap(x => x.results)), inspections: inspected.flatMap(x => x.results) }, errors: [...pageData.errors, ...inspected.flatMap(x => x.errors)] }; } });
18
+ app.tool({
19
+ name: operation,
20
+ description: `Get normalized ${operation} metrics with partial provider errors.`,
21
+ inputSchema: z.object({
22
+ ...base,
23
+ limit: z.number().int().min(1).max(25000).optional(),
24
+ sort: z.enum(['clicks', 'impressions', 'ctr', 'position']).optional(),
25
+ }),
26
+ handler: (a) => service.metrics(operation, a, a.engines),
27
+ });
28
+ app.tool({
29
+ name: 'sitemaps',
30
+ description: 'List submitted sitemaps for a verified site.',
31
+ inputSchema: z.object({ site: z.string(), engines, account: z.string().optional() }),
32
+ handler: (a) => service.sitemaps(a.site, a.engines, a.account),
33
+ });
34
+ app.tool({
35
+ name: 'inspect_url',
36
+ description: 'Inspect URL indexing where provider API offers evidence; unknown means unverified.',
37
+ inputSchema: z.object({ site: z.string(), url: z.url(), engines, account: z.string().optional() }),
38
+ handler: (a) => service.inspect(a.site, a.url, a.engines, a.account),
39
+ });
40
+ app.tool({
41
+ name: 'compare_engines',
42
+ description: 'Compare provider-reported metrics without equating their counting semantics.',
43
+ inputSchema: z.object(base),
44
+ handler: async (a) => {
45
+ const data = await service.metrics('performance', a, a.engines);
46
+ return { ...data, results: compareEngines(data.results) };
47
+ },
48
+ });
49
+ app.tool({
50
+ name: 'opportunities',
51
+ description: 'Deterministic CTR, ranking and trend candidates with supporting metrics.',
52
+ inputSchema: z.object({
53
+ ...base,
54
+ minImpressions: z.number().min(0).default(100),
55
+ maxCtr: z.number().min(0).max(1).default(0.02),
56
+ declineFraction: z.number().min(0).max(1).default(0.2),
57
+ }),
58
+ handler: async (a) => {
59
+ const prior = previousRange(a.from, a.to);
60
+ const [queries, pages, oldQueries, oldPages] = await Promise.all([
61
+ service.metrics('queries', a, a.engines),
62
+ service.metrics('pages', a, a.engines),
63
+ service.metrics('queries', { ...a, ...prior }, a.engines),
64
+ service.metrics('pages', { ...a, ...prior }, a.engines),
65
+ ]);
66
+ return {
67
+ results: [
68
+ ...findCtrOpportunities(queries.results, { minImpressions: a.minImpressions, maxCtr: a.maxCtr }),
69
+ ...findNearFirstPage(queries.results),
70
+ ...findDecliningPages(pages.results, oldPages.results, a.declineFraction),
71
+ ...findDecliningQueries(queries.results, oldQueries.results, a.declineFraction),
72
+ ...findImpressionGrowthWithoutClicks(queries.results, oldQueries.results),
73
+ ],
74
+ errors: [...queries.errors, ...pages.errors, ...oldQueries.errors, ...oldPages.errors],
75
+ };
76
+ },
77
+ });
78
+ app.tool({
79
+ name: 'indexing_gaps',
80
+ description: 'Find verified indexing differences for supplied URLs and search visibility gaps. Missing performance rows are not indexing proof.',
81
+ inputSchema: z.object({ ...base, urls: z.array(z.url()).max(20).optional() }),
82
+ handler: async (a) => {
83
+ const pageData = await service.metrics('pages', a, a.engines);
84
+ const selected = (a.engines ?? ['google', 'bing', 'yandex']).filter((engine) => !pageData.errors.some((e) => e.engine === engine));
85
+ const inspected = await Promise.all((a.urls ?? []).map((url) => service.inspect(a.site, url, a.engines, a.account)));
86
+ return {
87
+ results: {
88
+ visibilityGaps: findEngineGaps(pageData.results, selected),
89
+ verifiedIndexingGaps: findIndexingGaps(inspected.flatMap((x) => x.results)),
90
+ inspections: inspected.flatMap((x) => x.results),
91
+ },
92
+ errors: [...pageData.errors, ...inspected.flatMap((x) => x.errors)],
93
+ };
94
+ },
95
+ });
15
96
  }
@@ -1,8 +1,8 @@
1
+ import { secretPrompt } from '../../core/auth/prompt.js';
1
2
  import { WebmasterError } from '../../core/errors.js';
2
- import { providerScheduler, jsonFetch } from '../../core/scheduler.js';
3
- import { metric, finite } from '../../core/normalize/index.js';
3
+ import { finite, metric } from '../../core/normalize/index.js';
4
+ import { jsonFetch, providerScheduler } from '../../core/scheduler.js';
4
5
  import { validateSite } from '../../core/validation.js';
5
- import { secretPrompt } from '../../core/auth/prompt.js';
6
6
  export class BingProvider {
7
7
  store;
8
8
  account;
@@ -14,18 +14,72 @@ export class BingProvider {
14
14
  this.account = account;
15
15
  this.fetcher = fetcher;
16
16
  }
17
- supports(c) { return ['sites', 'performance', 'queries', 'pages', 'sitemaps'].includes(c); }
18
- async authenticate() { const key = await secretPrompt('Bing Webmaster API key: '); if (!key)
19
- throw new WebmasterError('INVALID_INPUT', 'Empty Bing API key.'); await this.store.set('bing', this.account, { apiKey: key }); }
20
- async isAuthenticated() { return !!(await this.store.get('bing', this.account)); }
21
- async request(method, params = {}) { const credential = await this.store.get('bing', this.account); if (!credential?.apiKey)
22
- throw new WebmasterError('AUTH_FAILED', 'Bing is not connected. Run auth bing.'); const url = new URL(`https://ssl.bing.com/webmaster/api.svc/json/${method}`); url.searchParams.set('apikey', credential.apiKey); for (const [k, v] of Object.entries(params))
23
- url.searchParams.set(k, v); return this.scheduler.run(async () => { const data = await jsonFetch(this.fetcher, url.href); if (data?.ErrorCode)
24
- throw new WebmasterError(data.ErrorCode === 3 ? 'AUTH_FAILED' : 'UPSTREAM_ERROR', 'Bing rejected the request.'); return data.d; }); }
25
- async getSites() { const rows = await this.request('GetUserSites'); return (rows ?? []).filter((x) => x.IsVerified).map((x) => ({ site: x.Url, engine: this.id, account: this.account })); }
26
- async stats(input, method, dimension) { validateSite(input.site); const rows = await this.request(method, { siteUrl: input.site }); return (rows ?? []).map((r) => { const match = String(r.Date ?? '').match(/\/Date\((\d+)/); const date = match ? new Date(Number(match[1])).toISOString().slice(0, 10) : String(r.Date ?? '').slice(0, 10); return metric({ engine: this.id, site: input.site, account: this.account, date, ...(dimension ? { [dimension]: r.Query } : {}), clicks: finite(r.Clicks), impressions: finite(r.Impressions), position: finite(r.AvgImpressionPosition) }); }).filter((r) => r.date && r.date >= input.from && r.date <= input.to).slice(0, input.limit ?? 25000); }
27
- getPerformance(input) { return this.stats(input, 'GetRankAndTrafficStats'); }
28
- getPages(input) { return this.stats(input, 'GetPageStats', 'page'); }
29
- getQueries(input) { return this.stats(input, 'GetQueryStats', 'query'); }
30
- async getSitemaps(site) { validateSite(site); const rows = await this.request('GetFeeds', { siteUrl: site }); return (rows ?? []).map((r) => ({ engine: this.id, site, url: r.Url ?? r.FeedUrl, status: r.Status })); }
17
+ supports(c) {
18
+ return ['sites', 'performance', 'queries', 'pages', 'sitemaps'].includes(c);
19
+ }
20
+ async authenticate() {
21
+ const key = await secretPrompt('Bing Webmaster API key: ');
22
+ if (!key)
23
+ throw new WebmasterError('INVALID_INPUT', 'Empty Bing API key.');
24
+ await this.store.set('bing', this.account, { apiKey: key });
25
+ }
26
+ async isAuthenticated() {
27
+ return !!(await this.store.get('bing', this.account));
28
+ }
29
+ async request(method, params = {}) {
30
+ const credential = (await this.store.get('bing', this.account));
31
+ if (!credential?.apiKey)
32
+ throw new WebmasterError('AUTH_FAILED', 'Bing is not connected. Run auth bing.');
33
+ const url = new URL(`https://ssl.bing.com/webmaster/api.svc/json/${method}`);
34
+ url.searchParams.set('apikey', credential.apiKey);
35
+ for (const [k, v] of Object.entries(params))
36
+ url.searchParams.set(k, v);
37
+ return this.scheduler.run(async () => {
38
+ const data = await jsonFetch(this.fetcher, url.href);
39
+ if (data?.ErrorCode)
40
+ throw new WebmasterError(data.ErrorCode === 3 ? 'AUTH_FAILED' : 'UPSTREAM_ERROR', 'Bing rejected the request.');
41
+ return data.d;
42
+ });
43
+ }
44
+ async getSites() {
45
+ const rows = await this.request('GetUserSites');
46
+ return (rows ?? [])
47
+ .filter((x) => x.IsVerified)
48
+ .map((x) => ({ site: x.Url, engine: this.id, account: this.account }));
49
+ }
50
+ async stats(input, method, dimension) {
51
+ validateSite(input.site);
52
+ const rows = await this.request(method, { siteUrl: input.site });
53
+ return (rows ?? [])
54
+ .map((r) => {
55
+ const match = String(r.Date ?? '').match(/\/Date\((\d+)/);
56
+ const date = match ? new Date(Number(match[1])).toISOString().slice(0, 10) : String(r.Date ?? '').slice(0, 10);
57
+ return metric({
58
+ engine: this.id,
59
+ site: input.site,
60
+ account: this.account,
61
+ date,
62
+ ...(dimension ? { [dimension]: r.Query } : {}),
63
+ clicks: finite(r.Clicks),
64
+ impressions: finite(r.Impressions),
65
+ position: finite(r.AvgImpressionPosition),
66
+ });
67
+ })
68
+ .filter((r) => r.date && r.date >= input.from && r.date <= input.to)
69
+ .slice(0, input.limit ?? 25000);
70
+ }
71
+ getPerformance(input) {
72
+ return this.stats(input, 'GetRankAndTrafficStats');
73
+ }
74
+ getPages(input) {
75
+ return this.stats(input, 'GetPageStats', 'page');
76
+ }
77
+ getQueries(input) {
78
+ return this.stats(input, 'GetQueryStats', 'query');
79
+ }
80
+ async getSitemaps(site) {
81
+ validateSite(site);
82
+ const rows = await this.request('GetFeeds', { siteUrl: site });
83
+ return (rows ?? []).map((r) => ({ engine: this.id, site, url: r.Url ?? r.FeedUrl, status: r.Status }));
84
+ }
31
85
  }