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,10 +1,16 @@
1
- import { randomBytes, createHash } from 'node:crypto';
2
1
  import { spawn } from 'node:child_process';
3
- import { Hono } from 'hono';
2
+ import { createHash, randomBytes } from 'node:crypto';
4
3
  import { serve } from '@hono/node-server';
4
+ import { Hono } from 'hono';
5
5
  import { WebmasterError } from '../../core/errors.js';
6
6
  import { jsonFetch } from '../../core/scheduler.js';
7
- export function openBrowser(url) { const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32.exe' : 'xdg-open'; const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url]; const child = spawn(command, args, { stdio: 'ignore', detached: true }); child.on('error', () => { }); child.unref(); }
7
+ export function openBrowser(url) {
8
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32.exe' : 'xdg-open';
9
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
10
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
11
+ child.on('error', () => { });
12
+ child.unref();
13
+ }
8
14
  export async function authenticateGoogle(store, account, write = false, fetcher = fetch) {
9
15
  const clientId = process.env.GOOGLE_CLIENT_ID, clientSecret = process.env.GOOGLE_CLIENT_SECRET;
10
16
  if (!clientId)
@@ -12,46 +18,103 @@ export async function authenticateGoogle(store, account, write = false, fetcher
12
18
  const state = randomBytes(24).toString('base64url'), verifier = randomBytes(48).toString('base64url');
13
19
  const challenge = createHash('sha256').update(verifier).digest('base64url');
14
20
  let resolveCode, rejectCode;
15
- const codePromise = new Promise((resolve, reject) => { resolveCode = resolve; rejectCode = reject; });
21
+ const codePromise = new Promise((resolve, reject) => {
22
+ resolveCode = resolve;
23
+ rejectCode = reject;
24
+ });
16
25
  const app = new Hono();
17
- app.get('/callback', c => { if (c.req.query('state') !== state) {
18
- return c.text('Invalid OAuth state.', 400);
19
- } const code = c.req.query('code'); if (!code) {
20
- rejectCode(new WebmasterError('AUTH_FAILED', 'OAuth authorization was declined.'));
21
- return c.text('Authorization failed.', 400);
22
- } resolveCode(code); return c.text('Authorization complete. You can close this tab.'); });
23
- const server = await new Promise(resolve => { const s = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' }, () => resolve(s)); });
26
+ app.get('/callback', (c) => {
27
+ if (c.req.query('state') !== state) {
28
+ return c.text('Invalid OAuth state.', 400);
29
+ }
30
+ const code = c.req.query('code');
31
+ if (!code) {
32
+ rejectCode(new WebmasterError('AUTH_FAILED', 'OAuth authorization was declined.'));
33
+ return c.text('Authorization failed.', 400);
34
+ }
35
+ resolveCode(code);
36
+ return c.text('Authorization complete. You can close this tab.');
37
+ });
38
+ const server = await new Promise((resolve) => {
39
+ const s = serve({ fetch: app.fetch, port: 0, hostname: '127.0.0.1' }, () => resolve(s));
40
+ });
24
41
  let timer;
25
42
  try {
26
43
  const address = server.address();
27
44
  if (!address || typeof address === 'string')
28
45
  throw new Error('No callback port');
29
46
  const redirect = `http://127.0.0.1:${address.port}/callback`;
30
- const scope = write ? 'https://www.googleapis.com/auth/webmasters' : 'https://www.googleapis.com/auth/webmasters.readonly';
47
+ const scope = write
48
+ ? 'https://www.googleapis.com/auth/webmasters'
49
+ : 'https://www.googleapis.com/auth/webmasters.readonly';
31
50
  const authorization = new URL('https://accounts.google.com/o/oauth2/v2/auth');
32
- authorization.search = new URLSearchParams({ client_id: clientId, redirect_uri: redirect, response_type: 'code', scope, access_type: 'offline', prompt: 'consent', state, code_challenge: challenge, code_challenge_method: 'S256' }).toString();
51
+ authorization.search = new URLSearchParams({
52
+ client_id: clientId,
53
+ redirect_uri: redirect,
54
+ response_type: 'code',
55
+ scope,
56
+ access_type: 'offline',
57
+ prompt: 'consent',
58
+ state,
59
+ code_challenge: challenge,
60
+ code_challenge_method: 'S256',
61
+ }).toString();
33
62
  process.stderr.write(`Open this URL if your browser does not start:\n${authorization.href}\n`);
34
63
  openBrowser(authorization.href);
35
- const code = await Promise.race([codePromise, new Promise((_, reject) => { timer = setTimeout(() => reject(new WebmasterError('AUTH_FAILED', 'OAuth callback timed out after two minutes.')), 120000); })]);
36
- const body = new URLSearchParams({ client_id: clientId, code, code_verifier: verifier, redirect_uri: redirect, grant_type: 'authorization_code' });
64
+ const code = await Promise.race([
65
+ codePromise,
66
+ new Promise((_, reject) => {
67
+ timer = setTimeout(() => reject(new WebmasterError('AUTH_FAILED', 'OAuth callback timed out after two minutes.')), 120000);
68
+ }),
69
+ ]);
70
+ const body = new URLSearchParams({
71
+ client_id: clientId,
72
+ code,
73
+ code_verifier: verifier,
74
+ redirect_uri: redirect,
75
+ grant_type: 'authorization_code',
76
+ });
37
77
  if (clientSecret)
38
78
  body.set('client_secret', clientSecret);
39
79
  const data = await jsonFetch(fetcher, 'https://oauth2.googleapis.com/token', { method: 'POST', body });
40
80
  if (!data.access_token)
41
81
  throw new WebmasterError('AUTH_FAILED', 'Google did not issue an access token.');
42
- const credentials = { accessToken: data.access_token, refreshToken: data.refresh_token, expiresAt: Date.now() + Number(data.expires_in ?? 3600) * 1000, scope };
82
+ const credentials = {
83
+ accessToken: data.access_token,
84
+ refreshToken: data.refresh_token,
85
+ expiresAt: Date.now() + Number(data.expires_in ?? 3600) * 1000,
86
+ scope,
87
+ };
43
88
  await store.set('google', account, credentials);
44
89
  }
45
90
  finally {
46
91
  if (timer)
47
92
  clearTimeout(timer);
48
- await new Promise(resolve => server.close(() => resolve()));
93
+ await new Promise((resolve) => server.close(() => resolve()));
49
94
  }
50
95
  }
51
- export async function googleToken(store, account, fetcher = fetch) { const c = await store.get('google', account); if (!c?.accessToken)
52
- throw new WebmasterError('AUTH_FAILED', 'Google is not connected. Run auth google.'); if (!c.expiresAt || c.expiresAt > Date.now() + 60000)
53
- return c.accessToken; if (!c.refreshToken)
54
- throw new WebmasterError('AUTH_FAILED', 'Google token expired. Run auth google again.'); const clientId = process.env.GOOGLE_CLIENT_ID; if (!clientId)
55
- throw new WebmasterError('AUTH_FAILED', 'GOOGLE_CLIENT_ID is required to refresh the token.'); const body = new URLSearchParams({ client_id: clientId, refresh_token: c.refreshToken, grant_type: 'refresh_token' }); if (process.env.GOOGLE_CLIENT_SECRET)
56
- body.set('client_secret', process.env.GOOGLE_CLIENT_SECRET); const data = await jsonFetch(fetcher, 'https://oauth2.googleapis.com/token', { method: 'POST', body }); if (!data.access_token)
57
- throw new WebmasterError('AUTH_FAILED', 'Google token refresh failed.'); const updated = { ...c, accessToken: data.access_token, expiresAt: Date.now() + Number(data.expires_in ?? 3600) * 1000 }; await store.set('google', account, updated); return updated.accessToken; }
96
+ export async function googleToken(store, account, fetcher = fetch) {
97
+ const c = (await store.get('google', account));
98
+ if (!c?.accessToken)
99
+ throw new WebmasterError('AUTH_FAILED', 'Google is not connected. Run auth google.');
100
+ if (!c.expiresAt || c.expiresAt > Date.now() + 60000)
101
+ return c.accessToken;
102
+ if (!c.refreshToken)
103
+ throw new WebmasterError('AUTH_FAILED', 'Google token expired. Run auth google again.');
104
+ const clientId = process.env.GOOGLE_CLIENT_ID;
105
+ if (!clientId)
106
+ throw new WebmasterError('AUTH_FAILED', 'GOOGLE_CLIENT_ID is required to refresh the token.');
107
+ const body = new URLSearchParams({ client_id: clientId, refresh_token: c.refreshToken, grant_type: 'refresh_token' });
108
+ if (process.env.GOOGLE_CLIENT_SECRET)
109
+ body.set('client_secret', process.env.GOOGLE_CLIENT_SECRET);
110
+ const data = await jsonFetch(fetcher, 'https://oauth2.googleapis.com/token', { method: 'POST', body });
111
+ if (!data.access_token)
112
+ throw new WebmasterError('AUTH_FAILED', 'Google token refresh failed.');
113
+ const updated = {
114
+ ...c,
115
+ accessToken: data.access_token,
116
+ expiresAt: Date.now() + Number(data.expires_in ?? 3600) * 1000,
117
+ };
118
+ await store.set('google', account, updated);
119
+ return updated.accessToken;
120
+ }
@@ -1,5 +1,5 @@
1
- import { providerScheduler, jsonFetch } from '../../core/scheduler.js';
2
1
  import { metric } from '../../core/normalize/index.js';
2
+ import { jsonFetch, providerScheduler } from '../../core/scheduler.js';
3
3
  import { validateSite, validateUrl } from '../../core/validation.js';
4
4
  import { authenticateGoogle, googleToken } from './auth.js';
5
5
  export class GoogleProvider {
@@ -15,21 +15,101 @@ export class GoogleProvider {
15
15
  this.fetcher = fetcher;
16
16
  this.write = write;
17
17
  }
18
- supports(c) { return ['sites', 'performance', 'queries', 'pages', 'sitemaps', 'urlInspection', 'indexing'].includes(c); }
19
- authenticate() { return authenticateGoogle(this.store, this.account, this.write, this.fetcher); }
20
- async isAuthenticated() { return !!(await this.store.get('google', this.account)); }
21
- async request(url, body) { return this.scheduler.run(async () => jsonFetch(this.fetcher, url, { method: body ? 'POST' : 'GET', headers: { Authorization: `Bearer ${await googleToken(this.store, this.account, this.fetcher)}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined })); }
22
- async getSites() { const data = await this.request('https://www.googleapis.com/webmasters/v3/sites'); return (data.siteEntry ?? []).map((s) => ({ site: s.siteUrl, engine: this.id, account: this.account, permission: s.permissionLevel })); }
23
- async search(input, dimensions) { validateSite(input.site); const site = encodeURIComponent(input.site); const result = []; const limit = Math.min(input.limit ?? 25000, 25000); for (let startRow = 0; startRow < limit; startRow += 25000) {
24
- const data = await this.request(`https://www.googleapis.com/webmasters/v3/sites/${site}/searchAnalytics/query`, { startDate: input.from, endDate: input.to, dimensions, rowLimit: Math.min(25000, limit - startRow), startRow });
25
- const rows = data.rows ?? [];
26
- result.push(...rows.map((r) => metric({ engine: this.id, site: input.site, account: this.account, ...Object.fromEntries(dimensions.map((d, i) => [d, r.keys?.[i]])), clicks: r.clicks, impressions: r.impressions, ctr: r.ctr, position: r.position })));
27
- if (rows.length < Math.min(25000, limit - startRow))
28
- break;
29
- } return result; }
30
- getPerformance(input) { return this.search(input, ['date']); }
31
- getPages(input) { return this.search(input, ['page']); }
32
- getQueries(input) { return this.search(input, ['query']); }
33
- async getSitemaps(site) { validateSite(site); const d = await this.request(`https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(site)}/sitemaps`); return (d.sitemap ?? []).map((s) => ({ engine: this.id, site, url: s.path, submitted: s.lastSubmitted, status: s.isPending ? 'pending' : undefined })); }
34
- async inspectUrl(site, url) { validateUrl(site, url); const d = await this.request('https://searchconsole.googleapis.com/v1/urlInspection/index:inspect', { siteUrl: site, inspectionUrl: url }); const status = d.inspectionResult?.indexStatusResult; return { engine: this.id, site, url, state: status?.verdict === 'PASS' ? 'indexed' : status?.verdict === 'FAIL' || status?.verdict === 'NEUTRAL' ? 'not_indexed' : 'unknown', reason: status?.coverageState, inspectedAt: status?.lastCrawlTime }; }
18
+ supports(c) {
19
+ return ['sites', 'performance', 'queries', 'pages', 'sitemaps', 'urlInspection', 'indexing'].includes(c);
20
+ }
21
+ authenticate() {
22
+ return authenticateGoogle(this.store, this.account, this.write, this.fetcher);
23
+ }
24
+ async isAuthenticated() {
25
+ return !!(await this.store.get('google', this.account));
26
+ }
27
+ async request(url, body) {
28
+ return this.scheduler.run(async () => jsonFetch(this.fetcher, url, {
29
+ method: body ? 'POST' : 'GET',
30
+ headers: {
31
+ Authorization: `Bearer ${await googleToken(this.store, this.account, this.fetcher)}`,
32
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
33
+ },
34
+ body: body ? JSON.stringify(body) : undefined,
35
+ }));
36
+ }
37
+ async getSites() {
38
+ const data = await this.request('https://www.googleapis.com/webmasters/v3/sites');
39
+ return (data.siteEntry ?? []).map((s) => ({
40
+ site: s.siteUrl,
41
+ engine: this.id,
42
+ account: this.account,
43
+ permission: s.permissionLevel,
44
+ }));
45
+ }
46
+ async search(input, dimensions) {
47
+ validateSite(input.site);
48
+ const site = encodeURIComponent(input.site);
49
+ const result = [];
50
+ const limit = Math.min(input.limit ?? 25000, 25000);
51
+ for (let startRow = 0; startRow < limit; startRow += 25000) {
52
+ const data = await this.request(`https://www.googleapis.com/webmasters/v3/sites/${site}/searchAnalytics/query`, {
53
+ startDate: input.from,
54
+ endDate: input.to,
55
+ dimensions,
56
+ rowLimit: Math.min(25000, limit - startRow),
57
+ startRow,
58
+ });
59
+ const rows = data.rows ?? [];
60
+ result.push(...rows.map((r) => metric({
61
+ engine: this.id,
62
+ site: input.site,
63
+ account: this.account,
64
+ ...Object.fromEntries(dimensions.map((d, i) => [d, r.keys?.[i]])),
65
+ clicks: r.clicks,
66
+ impressions: r.impressions,
67
+ ctr: r.ctr,
68
+ position: r.position,
69
+ })));
70
+ if (rows.length < Math.min(25000, limit - startRow))
71
+ break;
72
+ }
73
+ return result;
74
+ }
75
+ getPerformance(input) {
76
+ return this.search(input, ['date']);
77
+ }
78
+ getPages(input) {
79
+ return this.search(input, ['page']);
80
+ }
81
+ getQueries(input) {
82
+ return this.search(input, ['query']);
83
+ }
84
+ async getSitemaps(site) {
85
+ validateSite(site);
86
+ const d = await this.request(`https://www.googleapis.com/webmasters/v3/sites/${encodeURIComponent(site)}/sitemaps`);
87
+ return (d.sitemap ?? []).map((s) => ({
88
+ engine: this.id,
89
+ site,
90
+ url: s.path,
91
+ submitted: s.lastSubmitted,
92
+ status: s.isPending ? 'pending' : undefined,
93
+ }));
94
+ }
95
+ async inspectUrl(site, url) {
96
+ validateUrl(site, url);
97
+ const d = await this.request('https://searchconsole.googleapis.com/v1/urlInspection/index:inspect', {
98
+ siteUrl: site,
99
+ inspectionUrl: url,
100
+ });
101
+ const status = d.inspectionResult?.indexStatusResult;
102
+ return {
103
+ engine: this.id,
104
+ site,
105
+ url,
106
+ state: status?.verdict === 'PASS'
107
+ ? 'indexed'
108
+ : status?.verdict === 'FAIL' || status?.verdict === 'NEUTRAL'
109
+ ? 'not_indexed'
110
+ : 'unknown',
111
+ reason: status?.coverageState,
112
+ inspectedAt: status?.lastCrawlTime,
113
+ };
114
+ }
35
115
  }
@@ -1,9 +1,26 @@
1
+ import { secretPrompt } from '../../core/auth/prompt.js';
1
2
  import { WebmasterError } from '../../core/errors.js';
2
3
  import { openBrowser } from '../google/auth.js';
3
- import { secretPrompt } from '../../core/auth/prompt.js';
4
- export function yandexAuthorizationUrl() { const clientId = process.env.YANDEX_CLIENT_ID; if (!clientId)
5
- throw new WebmasterError('INVALID_INPUT', 'Set YANDEX_CLIENT_ID for a Yandex OAuth app with webmaster:hostinfo permission.'); const url = new URL('https://oauth.yandex.com/authorize'); url.searchParams.set('response_type', 'token'); url.searchParams.set('client_id', clientId); return url.href; }
6
- export function beginYandexAuth() { const url = yandexAuthorizationUrl(); process.stderr.write(`Open Yandex OAuth and copy the displayed token:\n${url}\n`); openBrowser(url); }
7
- export async function saveYandexToken(store, account, token) { if (!/^[A-Za-z0-9._~-]{10,}$/.test(token))
8
- throw new WebmasterError('INVALID_INPUT', 'Invalid Yandex OAuth token format.'); await store.set('yandex', account, { accessToken: token }); }
9
- export async function authenticateYandex(store, account) { beginYandexAuth(); await saveYandexToken(store, account, await secretPrompt('Yandex OAuth token: ')); }
4
+ export function yandexAuthorizationUrl() {
5
+ const clientId = process.env.YANDEX_CLIENT_ID;
6
+ if (!clientId)
7
+ throw new WebmasterError('INVALID_INPUT', 'Set YANDEX_CLIENT_ID for a Yandex OAuth app with webmaster:hostinfo permission.');
8
+ const url = new URL('https://oauth.yandex.com/authorize');
9
+ url.searchParams.set('response_type', 'token');
10
+ url.searchParams.set('client_id', clientId);
11
+ return url.href;
12
+ }
13
+ export function beginYandexAuth() {
14
+ const url = yandexAuthorizationUrl();
15
+ process.stderr.write(`Open Yandex OAuth and copy the displayed token:\n${url}\n`);
16
+ openBrowser(url);
17
+ }
18
+ export async function saveYandexToken(store, account, token) {
19
+ if (!/^[A-Za-z0-9._~-]{10,}$/.test(token))
20
+ throw new WebmasterError('INVALID_INPUT', 'Invalid Yandex OAuth token format.');
21
+ await store.set('yandex', account, { accessToken: token });
22
+ }
23
+ export async function authenticateYandex(store, account) {
24
+ beginYandexAuth();
25
+ await saveYandexToken(store, account, await secretPrompt('Yandex OAuth token: '));
26
+ }
@@ -1,6 +1,6 @@
1
1
  import { WebmasterError } from '../../core/errors.js';
2
- import { providerScheduler, jsonFetch } from '../../core/scheduler.js';
3
- import { metric, finite } from '../../core/normalize/index.js';
2
+ import { finite, metric } from '../../core/normalize/index.js';
3
+ import { jsonFetch, providerScheduler } from '../../core/scheduler.js';
4
4
  import { validateSite, validateUrl } from '../../core/validation.js';
5
5
  import { authenticateYandex } from './auth.js';
6
6
  export class YandexProvider {
@@ -14,57 +14,159 @@ export class YandexProvider {
14
14
  this.account = account;
15
15
  this.fetcher = fetcher;
16
16
  }
17
- supports(c) { return ['sites', 'performance', 'queries', 'pages', 'sitemaps', 'urlInspection', 'indexing'].includes(c); }
18
- authenticate() { return authenticateYandex(this.store, this.account); }
19
- async isAuthenticated() { return !!(await this.store.get('yandex', this.account)); }
20
- async request(path, body) { const credential = await this.store.get('yandex', this.account); if (!credential?.accessToken)
21
- throw new WebmasterError('AUTH_FAILED', 'Yandex is not connected. Run auth yandex.'); return this.scheduler.run(() => jsonFetch(this.fetcher, `https://api.webmaster.yandex.net/v4/${path}`, { method: body ? 'POST' : 'GET', headers: { Authorization: `OAuth ${credential.accessToken}`, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined })); }
22
- async userId() { const d = await this.request('user'); return String(d.user_id); }
23
- async host(site) { validateSite(site); const user = await this.userId(); const d = await this.request(`user/${encodeURIComponent(user)}/hosts`); const found = (d.hosts ?? []).find((h) => h.ascii_host_url === site || h.unicode_host_url === site); if (!found)
24
- throw new WebmasterError('PERMISSION_DENIED', 'Site is not in this Yandex account.'); return { user, host: found.host_id }; }
25
- async getSites() { const user = await this.userId(); const d = await this.request(`user/${encodeURIComponent(user)}/hosts`); return (d.hosts ?? []).filter((h) => h.verified).map((h) => ({ site: h.ascii_host_url, engine: this.id, account: this.account })); }
26
- async getPerformance(input) { const { user, host } = await this.host(input.site); const url = new URL(`https://api.webmaster.yandex.net/v4/user/${encodeURIComponent(user)}/hosts/${encodeURIComponent(host)}/search-queries/all/history`); for (const x of ['TOTAL_SHOWS', 'TOTAL_CLICKS', 'AVG_SHOW_POSITION'])
27
- url.searchParams.append('query_indicator', x); url.searchParams.set('date_from', input.from); url.searchParams.set('date_to', input.to); const d = await this.request(url.pathname.slice(4) + url.search); const byDate = new Map(); for (const [key, items] of Object.entries(d.indicators ?? {}))
28
- for (const item of items) {
29
- const date = String(item.date).slice(0, 10);
30
- const row = byDate.get(date) ?? { engine: this.id, site: input.site, account: this.account, date };
31
- if (key === 'TOTAL_SHOWS')
32
- row.impressions = finite(item.value);
33
- if (key === 'TOTAL_CLICKS')
34
- row.clicks = finite(item.value);
35
- if (key === 'AVG_SHOW_POSITION')
36
- row.position = finite(item.value);
37
- byDate.set(date, row);
38
- } return [...byDate.values()].map(metric); }
39
- async getQueries(input) { const { user, host } = await this.host(input.site); const url = new URL(`https://api.webmaster.yandex.net/v4/user/${encodeURIComponent(user)}/hosts/${encodeURIComponent(host)}/search-queries/popular`); url.searchParams.set('order_by', 'TOTAL_CLICKS'); for (const x of ['TOTAL_SHOWS', 'TOTAL_CLICKS', 'AVG_SHOW_POSITION'])
40
- url.searchParams.append('query_indicator', x); url.searchParams.set('date_from', input.from); url.searchParams.set('date_to', input.to); const max = Math.min(input.limit ?? 500, 3000); const rows = []; for (let offset = 0; offset < max; offset += 500) {
41
- url.searchParams.set('offset', String(offset));
42
- url.searchParams.set('limit', String(Math.min(500, max - offset)));
17
+ supports(c) {
18
+ return ['sites', 'performance', 'queries', 'pages', 'sitemaps', 'urlInspection', 'indexing'].includes(c);
19
+ }
20
+ authenticate() {
21
+ return authenticateYandex(this.store, this.account);
22
+ }
23
+ async isAuthenticated() {
24
+ return !!(await this.store.get('yandex', this.account));
25
+ }
26
+ async request(path, body) {
27
+ const credential = (await this.store.get('yandex', this.account));
28
+ if (!credential?.accessToken)
29
+ throw new WebmasterError('AUTH_FAILED', 'Yandex is not connected. Run auth yandex.');
30
+ return this.scheduler.run(() => jsonFetch(this.fetcher, `https://api.webmaster.yandex.net/v4/${path}`, {
31
+ method: body ? 'POST' : 'GET',
32
+ headers: {
33
+ Authorization: `OAuth ${credential.accessToken}`,
34
+ ...(body ? { 'Content-Type': 'application/json' } : {}),
35
+ },
36
+ body: body ? JSON.stringify(body) : undefined,
37
+ }));
38
+ }
39
+ async userId() {
40
+ const d = await this.request('user');
41
+ return String(d.user_id);
42
+ }
43
+ async host(site) {
44
+ validateSite(site);
45
+ const user = await this.userId();
46
+ const d = await this.request(`user/${encodeURIComponent(user)}/hosts`);
47
+ const found = (d.hosts ?? []).find((h) => h.ascii_host_url === site || h.unicode_host_url === site);
48
+ if (!found)
49
+ throw new WebmasterError('PERMISSION_DENIED', 'Site is not in this Yandex account.');
50
+ return { user, host: found.host_id };
51
+ }
52
+ async getSites() {
53
+ const user = await this.userId();
54
+ const d = await this.request(`user/${encodeURIComponent(user)}/hosts`);
55
+ return (d.hosts ?? [])
56
+ .filter((h) => h.verified)
57
+ .map((h) => ({ site: h.ascii_host_url, engine: this.id, account: this.account }));
58
+ }
59
+ async getPerformance(input) {
60
+ const { user, host } = await this.host(input.site);
61
+ const url = new URL(`https://api.webmaster.yandex.net/v4/user/${encodeURIComponent(user)}/hosts/${encodeURIComponent(host)}/search-queries/all/history`);
62
+ for (const x of ['TOTAL_SHOWS', 'TOTAL_CLICKS', 'AVG_SHOW_POSITION'])
63
+ url.searchParams.append('query_indicator', x);
64
+ url.searchParams.set('date_from', input.from);
65
+ url.searchParams.set('date_to', input.to);
43
66
  const d = await this.request(url.pathname.slice(4) + url.search);
44
- const batch = d.queries ?? [];
45
- rows.push(...batch.map((q) => metric({ engine: this.id, site: input.site, account: this.account, query: q.query_text, clicks: q.indicators?.TOTAL_CLICKS, impressions: q.indicators?.TOTAL_SHOWS, position: q.indicators?.AVG_SHOW_POSITION })));
46
- if (batch.length < Math.min(500, max - offset))
47
- break;
48
- } return rows; }
49
- async getPages(input) { const earliest = new Date(Date.now() - 14 * 86400000).toISOString().slice(0, 10); if (input.from < earliest)
50
- throw new WebmasterError('UNSUPPORTED', 'Yandex page analytics is available only for the last two weeks.'); const { user, host } = await this.host(input.site); const rows = []; const max = input.limit ?? 500; for (let offset = 0; offset < max; offset += 500) {
51
- const d = await this.request(`user/${encodeURIComponent(user)}/hosts/${encodeURIComponent(host)}/query-analytics/list`, { offset, limit: Math.min(500, max - offset), text_indicator: 'URL', search_location: 'WEB_LOCATION' });
52
- const items = d.text_indicator_to_statistics ?? [];
53
- for (const item of items) {
54
- const dates = new Map();
55
- for (const stat of item.statistics ?? []) {
56
- if (stat.date < input.from || stat.date > input.to)
57
- continue;
58
- const row = dates.get(stat.date) ?? {};
59
- row[stat.field] = stat.value;
60
- dates.set(stat.date, row);
67
+ const byDate = new Map();
68
+ for (const [key, items] of Object.entries(d.indicators ?? {}))
69
+ for (const item of items) {
70
+ const date = String(item.date).slice(0, 10);
71
+ const row = byDate.get(date) ?? { engine: this.id, site: input.site, account: this.account, date };
72
+ if (key === 'TOTAL_SHOWS')
73
+ row.impressions = finite(item.value);
74
+ if (key === 'TOTAL_CLICKS')
75
+ row.clicks = finite(item.value);
76
+ if (key === 'AVG_SHOW_POSITION')
77
+ row.position = finite(item.value);
78
+ byDate.set(date, row);
61
79
  }
62
- for (const [date, s] of dates)
63
- rows.push(metric({ engine: this.id, site: input.site, account: this.account, page: item.text_indicator?.value, date, clicks: s.CLICKS, impressions: s.IMPRESSIONS, position: s.POSITION }));
80
+ return [...byDate.values()].map(metric);
81
+ }
82
+ async getQueries(input) {
83
+ const { user, host } = await this.host(input.site);
84
+ const url = new URL(`https://api.webmaster.yandex.net/v4/user/${encodeURIComponent(user)}/hosts/${encodeURIComponent(host)}/search-queries/popular`);
85
+ url.searchParams.set('order_by', 'TOTAL_CLICKS');
86
+ for (const x of ['TOTAL_SHOWS', 'TOTAL_CLICKS', 'AVG_SHOW_POSITION'])
87
+ url.searchParams.append('query_indicator', x);
88
+ url.searchParams.set('date_from', input.from);
89
+ url.searchParams.set('date_to', input.to);
90
+ const max = Math.min(input.limit ?? 500, 3000);
91
+ const rows = [];
92
+ for (let offset = 0; offset < max; offset += 500) {
93
+ url.searchParams.set('offset', String(offset));
94
+ url.searchParams.set('limit', String(Math.min(500, max - offset)));
95
+ const d = await this.request(url.pathname.slice(4) + url.search);
96
+ const batch = d.queries ?? [];
97
+ rows.push(...batch.map((q) => metric({
98
+ engine: this.id,
99
+ site: input.site,
100
+ account: this.account,
101
+ query: q.query_text,
102
+ clicks: q.indicators?.TOTAL_CLICKS,
103
+ impressions: q.indicators?.TOTAL_SHOWS,
104
+ position: q.indicators?.AVG_SHOW_POSITION,
105
+ })));
106
+ if (batch.length < Math.min(500, max - offset))
107
+ break;
108
+ }
109
+ return rows;
110
+ }
111
+ async getPages(input) {
112
+ const earliest = new Date(Date.now() - 14 * 86400000).toISOString().slice(0, 10);
113
+ if (input.from < earliest)
114
+ throw new WebmasterError('UNSUPPORTED', 'Yandex page analytics is available only for the last two weeks.');
115
+ const { user, host } = await this.host(input.site);
116
+ const rows = [];
117
+ const max = input.limit ?? 500;
118
+ for (let offset = 0; offset < max; offset += 500) {
119
+ const d = await this.request(`user/${encodeURIComponent(user)}/hosts/${encodeURIComponent(host)}/query-analytics/list`, { offset, limit: Math.min(500, max - offset), text_indicator: 'URL', search_location: 'WEB_LOCATION' });
120
+ const items = d.text_indicator_to_statistics ?? [];
121
+ for (const item of items) {
122
+ const dates = new Map();
123
+ for (const stat of item.statistics ?? []) {
124
+ if (stat.date < input.from || stat.date > input.to)
125
+ continue;
126
+ const row = dates.get(stat.date) ?? {};
127
+ row[stat.field] = stat.value;
128
+ dates.set(stat.date, row);
129
+ }
130
+ for (const [date, s] of dates)
131
+ rows.push(metric({
132
+ engine: this.id,
133
+ site: input.site,
134
+ account: this.account,
135
+ page: item.text_indicator?.value,
136
+ date,
137
+ clicks: s.CLICKS,
138
+ impressions: s.IMPRESSIONS,
139
+ position: s.POSITION,
140
+ }));
141
+ }
142
+ if (items.length < Math.min(500, max - offset))
143
+ break;
64
144
  }
65
- if (items.length < Math.min(500, max - offset))
66
- break;
67
- } return rows; }
68
- async getSitemaps(site) { const { user, host } = await this.host(site); const d = await this.request(`user/${encodeURIComponent(user)}/hosts/${encodeURIComponent(host)}/sitemaps`); return (d.sitemaps ?? []).map((s) => ({ engine: this.id, site, url: s.sitemap_url ?? s.url, status: s.status })); }
69
- async inspectUrl(site, url) { validateUrl(site, url); const { user, host } = await this.host(site); const d = await this.request(`user/${encodeURIComponent(user)}/hosts/${encodeURIComponent(host)}/important-urls`); const item = (d.urls ?? []).find((x) => x.url === url); const searchable = item?.search_status?.searchable; return { engine: this.id, site, url, state: searchable === true ? 'indexed' : searchable === false ? 'not_indexed' : 'unknown', reason: item ? item.search_status?.excluded_url_status : 'URL is not in Yandex important-page monitoring.', inspectedAt: item?.update_date }; }
145
+ return rows;
146
+ }
147
+ async getSitemaps(site) {
148
+ const { user, host } = await this.host(site);
149
+ const d = await this.request(`user/${encodeURIComponent(user)}/hosts/${encodeURIComponent(host)}/sitemaps`);
150
+ return (d.sitemaps ?? []).map((s) => ({
151
+ engine: this.id,
152
+ site,
153
+ url: s.sitemap_url ?? s.url,
154
+ status: s.status,
155
+ }));
156
+ }
157
+ async inspectUrl(site, url) {
158
+ validateUrl(site, url);
159
+ const { user, host } = await this.host(site);
160
+ const d = await this.request(`user/${encodeURIComponent(user)}/hosts/${encodeURIComponent(host)}/important-urls`);
161
+ const item = (d.urls ?? []).find((x) => x.url === url);
162
+ const searchable = item?.search_status?.searchable;
163
+ return {
164
+ engine: this.id,
165
+ site,
166
+ url,
167
+ state: searchable === true ? 'indexed' : searchable === false ? 'not_indexed' : 'unknown',
168
+ reason: item ? item.search_status?.excluded_url_status : 'URL is not in Yandex important-page monitoring.',
169
+ inspectedAt: item?.update_date,
170
+ };
171
+ }
70
172
  }
@@ -1,6 +1,6 @@
1
- import { Cache } from '../core/cache/index.js';
2
- import { KeychainCredentialStore } from '../core/auth/store.js';
3
1
  import { AccountRegistry } from '../core/auth/accounts.js';
2
+ import { KeychainCredentialStore } from '../core/auth/store.js';
3
+ import { Cache } from '../core/cache/index.js';
4
4
  import { WebmasterService } from '../core/service.js';
5
5
  export declare function createContext(): {
6
6
  cache: Cache;
@@ -1,10 +1,24 @@
1
- import { Cache } from '../core/cache/index.js';
2
- import { KeychainCredentialStore } from '../core/auth/store.js';
3
1
  import { AccountRegistry } from '../core/auth/accounts.js';
4
- import { GoogleProvider } from '../providers/google/index.js';
2
+ import { KeychainCredentialStore } from '../core/auth/store.js';
3
+ import { Cache } from '../core/cache/index.js';
4
+ import { WebmasterService } from '../core/service.js';
5
5
  import { BingProvider } from '../providers/bing/index.js';
6
+ import { GoogleProvider } from '../providers/google/index.js';
6
7
  import { YandexProvider } from '../providers/yandex/index.js';
7
- import { WebmasterService } from '../core/service.js';
8
- export function createContext() { const cache = new Cache(); const store = new KeychainCredentialStore(); const accounts = new AccountRegistry(cache, store); const buildProviders = () => { const providers = []; for (const engine of ['google', 'bing', 'yandex'])
9
- for (const name of accounts.names(engine))
10
- providers.push(engine === 'google' ? new GoogleProvider(store, name) : engine === 'bing' ? new BingProvider(store, name) : new YandexProvider(store, name)); return providers; }; return { cache, store, accounts, service: new WebmasterService(buildProviders(), cache, buildProviders) }; }
8
+ export function createContext() {
9
+ const cache = new Cache();
10
+ const store = new KeychainCredentialStore();
11
+ const accounts = new AccountRegistry(cache, store);
12
+ const buildProviders = () => {
13
+ const providers = [];
14
+ for (const engine of ['google', 'bing', 'yandex'])
15
+ for (const name of accounts.names(engine))
16
+ providers.push(engine === 'google'
17
+ ? new GoogleProvider(store, name)
18
+ : engine === 'bing'
19
+ ? new BingProvider(store, name)
20
+ : new YandexProvider(store, name));
21
+ return providers;
22
+ };
23
+ return { cache, store, accounts, service: new WebmasterService(buildProviders(), cache, buildProviders) };
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webmaster-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Local-first unified webmaster MCP for Google, Bing, and Yandex",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -15,6 +15,9 @@
15
15
  "build": "tsc -p tsconfig.json",
16
16
  "test": "node --import tsx --test test/*.test.ts",
17
17
  "postbuild": "node -e \"require('node:fs').chmodSync('dist/cli/index.js', 0o755)\"",
18
+ "lint": "biome check .",
19
+ "lint:fix": "biome check --write .",
20
+ "format": "biome format --write .",
18
21
  "prepack": "npm run build"
19
22
  },
20
23
  "keywords": [],
@@ -34,6 +37,7 @@
34
37
  "zod": "^4.6.5"
35
38
  },
36
39
  "devDependencies": {
40
+ "@biomejs/biome": "^2.5.14",
37
41
  "@types/better-sqlite3": "^9.6.0",
38
42
  "@types/node": "^26.6.2",
39
43
  "tsx": "^4.23.15",