@yoonion/mimi-seed-mcp 0.21.1 → 0.21.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/assets/agent-guide.md +1 -1
- package/dist/lib/tool-registrar.js +2 -1
- package/dist/naver/robots.d.ts +44 -0
- package/dist/naver/robots.js +126 -0
- package/dist/naver/tools.d.ts +127 -0
- package/dist/naver/tools.js +612 -0
- package/dist/registers/naver.d.ts +2 -0
- package/dist/registers/naver.js +21 -0
- package/dist/server.js +2 -0
- package/package.json +1 -1
- package/tool-manifest.json +19 -1
package/README.md
CHANGED
|
@@ -128,7 +128,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
|
|
|
128
128
|
---
|
|
129
129
|
|
|
130
130
|
<!-- generated:readme-tools-heading:start — edit scripts/docs-spec.mjs, then npm run plugin:sync -->
|
|
131
|
-
## 제공 도구 (150+ 개 ·
|
|
131
|
+
## 제공 도구 (150+ 개 · 23개 영역)
|
|
132
132
|
<!-- generated:readme-tools-heading:end -->
|
|
133
133
|
|
|
134
134
|
<!-- generated:readme-tools-table:start — edit scripts/docs-spec.mjs, then npm run plugin:sync -->
|
|
@@ -142,6 +142,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
|
|
|
142
142
|
| Jenkins (크리덴셜 + 잡 + 빌드) | 13 | `jenkins_create_credential` / `jenkins_upload_keystore` / `jenkins_create_job` / `jenkins_update_job` / `jenkins_trigger_build` |
|
|
143
143
|
| GA4 (Google Analytics 4) | 8 | `ga4_create_property` / `ga4_create_data_stream` / `ga4_plan_bigquery_link` / `ga4_create_bigquery_link` / `ga4_run_report` |
|
|
144
144
|
| Search Console | 6 | `gsc_inspect_url` / `gsc_search_analytics` / `gsc_submit_sitemap` |
|
|
145
|
+
| 네이버 서치어드바이저 | 2 | `naver_check_page` / `naver_indexnow_submit` |
|
|
145
146
|
| Google Ads | 6 | `googleads_list_campaigns` / `googleads_get_uac_report` / `googleads_get_campaign_report` |
|
|
146
147
|
| Facebook | 6 | `facebook_post_photo` / `facebook_post_multi_photo` / `facebook_list_pages` |
|
|
147
148
|
| Google Cloud IAM | 5 | `iam_create_service_account` / `iam_create_key` / `iam_add_iam_policy_binding` |
|
package/assets/agent-guide.md
CHANGED
|
@@ -78,7 +78,7 @@ row for the job; batching two rows in one `select:` call is fine.
|
|
|
78
78
|
| App Store weekly growth insight | `select:appstore_get_weekly_insight,appstore_get_sales_report` |
|
|
79
79
|
| AdMob | `select:admob_list_accounts,admob_list_apps,admob_create_app,admob_create_ad_unit,admob_list_ad_units,admob_get_today_earnings,admob_get_report` |
|
|
80
80
|
| Google Ads (UAC) | `select:googleads_config_status,googleads_save_config,googleads_list_accessible_customers,googleads_list_campaigns,googleads_get_campaign_report,googleads_get_uac_report` |
|
|
81
|
-
| Search Console | `select:gsc_list_sites,gsc_list_sitemaps,gsc_get_sitemap,gsc_submit_sitemap,gsc_inspect_url,gsc_search_analytics` |
|
|
81
|
+
| Search indexing (Search Console / Naver) | `select:gsc_list_sites,gsc_list_sitemaps,gsc_get_sitemap,gsc_submit_sitemap,gsc_inspect_url,gsc_search_analytics,naver_check_page,naver_indexnow_submit` |
|
|
82
82
|
| Social posting (Facebook / Instagram / Threads) | `select:facebook_current_config,facebook_save_config,facebook_list_pages,facebook_get_page,facebook_post_photo,facebook_post_multi_photo,instagram_save_config,instagram_get_account,instagram_post_image,instagram_post_carousel,threads_current_config,threads_save_config,threads_refresh_token,threads_get_account,threads_post,threads_post_video,threads_post_carousel` |
|
|
83
83
|
| TikTok Business video publish | `select:tiktok_business_auth_status,tiktok_business_get_account,tiktok_business_get_video_settings,tiktok_business_plan_video_post,tiktok_business_publish_video,tiktok_business_get_publish_status,tiktok_business_list_publish_audits` |
|
|
84
84
|
| Jenkins credentials + jobs | `select:jenkins_status,jenkins_save_config,jenkins_list_credentials,jenkins_create_credential,jenkins_delete_credential,jenkins_upload_keystore,jenkins_upload_playstore_sa,jenkins_list_jobs,jenkins_get_job_config,jenkins_create_job,jenkins_update_job` |
|
|
@@ -44,6 +44,7 @@ const BRAND_PREFIXES = [
|
|
|
44
44
|
['bigquery_', 'BigQuery'],
|
|
45
45
|
['ga4_', 'GA4'],
|
|
46
46
|
['gsc_', 'Search Console'],
|
|
47
|
+
['naver_', 'Naver'],
|
|
47
48
|
['googleads_', 'Google Ads'],
|
|
48
49
|
['facebook_', 'Facebook'],
|
|
49
50
|
['instagram_', 'Instagram'],
|
|
@@ -55,7 +56,7 @@ const BRAND_PREFIXES = [
|
|
|
55
56
|
['android_', 'Android'],
|
|
56
57
|
];
|
|
57
58
|
const WORD_CASE = {
|
|
58
|
-
iap: 'IAP', ios: 'iOS', sa: 'SA', url: 'URL', uac: 'UAC', ai: 'AI', iam: 'IAM', ga4: 'GA4',
|
|
59
|
+
iap: 'IAP', ios: 'iOS', sa: 'SA', url: 'URL', uac: 'UAC', ai: 'AI', iam: 'IAM', ga4: 'GA4', indexnow: 'IndexNow',
|
|
59
60
|
playstore: 'Play Store', appstore: 'App Store', bigquery: 'BigQuery', youtube: 'YouTube',
|
|
60
61
|
};
|
|
61
62
|
/** `playstore_submit_release` → `Play Store: Submit release`. */
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* robots.txt 해석 (RFC 9309) — 네이버 검색로봇(Yeti)이 한 경로를 긁을 수 있는지 판정한다.
|
|
3
|
+
*
|
|
4
|
+
* 순수 함수만 둔다(네트워크 없음). 판정 규칙:
|
|
5
|
+
* - 크롤러 토큰과 같은 `User-agent` 그룹이 있으면 그 그룹(여러 개면 합친다)만, 없으면 `*` 그룹을 쓴다.
|
|
6
|
+
* `Yeti` 그룹이 따로 있으면 `*` 의 규칙은 Yeti 에게 **적용되지 않는다** — 흔한 오해다.
|
|
7
|
+
* `User-agent: Yeti/1.1` 처럼 버전이 붙어도 제품 토큰(`[A-Za-z_-]+`)만 비교한다.
|
|
8
|
+
* - 규칙은 가장 긴 패턴이 이기고, 길이가 같으면 allow 가 이긴다. `*` 와 닫는 `$` 를 지원한다.
|
|
9
|
+
* - 값이 빈 `Disallow:` 는 규칙이 아니다(전부 허용).
|
|
10
|
+
* - 비교 전에 패턴과 경로를 같은 퍼센트 인코딩으로 맞춘다 — `Disallow: /블로그` 는 `/%EB%B8%94…` 를 막는다.
|
|
11
|
+
*
|
|
12
|
+
* 매칭은 정규식이 아니라 선형 와일드카드 매처다. robots.txt 는 **점검 대상 사이트가 쓴 입력**이라
|
|
13
|
+
* `/*a*a*a…b` 같은 패턴으로 정규식 백트래킹을 폭발시켜 stdio 서버 전체를 멈출 수 있다.
|
|
14
|
+
*/
|
|
15
|
+
export declare const NAVER_CRAWLER_TOKEN = "yeti";
|
|
16
|
+
export interface RobotsRule {
|
|
17
|
+
allow: boolean;
|
|
18
|
+
pattern: string;
|
|
19
|
+
}
|
|
20
|
+
export interface RobotsGroup {
|
|
21
|
+
agents: string[];
|
|
22
|
+
rules: RobotsRule[];
|
|
23
|
+
}
|
|
24
|
+
export interface ParsedRobots {
|
|
25
|
+
groups: RobotsGroup[];
|
|
26
|
+
sitemaps: string[];
|
|
27
|
+
}
|
|
28
|
+
export interface RobotsVerdict {
|
|
29
|
+
allowed: boolean;
|
|
30
|
+
/** 판정에 쓴 그룹 — 'yeti' 전용, '*', 또는 해당 그룹 없음. */
|
|
31
|
+
group: 'crawler' | 'wildcard' | 'none';
|
|
32
|
+
/** 판정을 결정한 규칙. 없으면 기본 허용. */
|
|
33
|
+
matchedRule?: RobotsRule;
|
|
34
|
+
}
|
|
35
|
+
export declare function parseRobots(text: string): ParsedRobots;
|
|
36
|
+
/** 비ASCII 는 UTF-8 퍼센트 인코딩, 기존 `%xx` 는 대문자 16진수로 — 패턴과 경로를 같은 표기로 맞춘다. */
|
|
37
|
+
export declare function normalizeRobotsPath(value: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* `*`(0자 이상)와 닫는 `$`(끝 고정)만 있는 와일드카드 매칭. 앞쪽은 항상 고정(접두어 매칭).
|
|
40
|
+
* 투 포인터 백트래킹이라 최악 O(패턴 × 경로) — 정규식처럼 지수적으로 터지지 않는다.
|
|
41
|
+
*/
|
|
42
|
+
export declare function robotsPatternMatches(pattern: string, path: string): boolean;
|
|
43
|
+
/** `path` 는 경로 + 쿼리(`/a?b=1`). 크롤러 토큰은 소문자로 비교한다. */
|
|
44
|
+
export declare function isAllowed(robots: ParsedRobots, path: string, crawler?: string): RobotsVerdict;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* robots.txt 해석 (RFC 9309) — 네이버 검색로봇(Yeti)이 한 경로를 긁을 수 있는지 판정한다.
|
|
3
|
+
*
|
|
4
|
+
* 순수 함수만 둔다(네트워크 없음). 판정 규칙:
|
|
5
|
+
* - 크롤러 토큰과 같은 `User-agent` 그룹이 있으면 그 그룹(여러 개면 합친다)만, 없으면 `*` 그룹을 쓴다.
|
|
6
|
+
* `Yeti` 그룹이 따로 있으면 `*` 의 규칙은 Yeti 에게 **적용되지 않는다** — 흔한 오해다.
|
|
7
|
+
* `User-agent: Yeti/1.1` 처럼 버전이 붙어도 제품 토큰(`[A-Za-z_-]+`)만 비교한다.
|
|
8
|
+
* - 규칙은 가장 긴 패턴이 이기고, 길이가 같으면 allow 가 이긴다. `*` 와 닫는 `$` 를 지원한다.
|
|
9
|
+
* - 값이 빈 `Disallow:` 는 규칙이 아니다(전부 허용).
|
|
10
|
+
* - 비교 전에 패턴과 경로를 같은 퍼센트 인코딩으로 맞춘다 — `Disallow: /블로그` 는 `/%EB%B8%94…` 를 막는다.
|
|
11
|
+
*
|
|
12
|
+
* 매칭은 정규식이 아니라 선형 와일드카드 매처다. robots.txt 는 **점검 대상 사이트가 쓴 입력**이라
|
|
13
|
+
* `/*a*a*a…b` 같은 패턴으로 정규식 백트래킹을 폭발시켜 stdio 서버 전체를 멈출 수 있다.
|
|
14
|
+
*/
|
|
15
|
+
export const NAVER_CRAWLER_TOKEN = 'yeti';
|
|
16
|
+
/** `Yeti/1.1 (+…)` → `yeti`. `*` 는 그대로. */
|
|
17
|
+
function agentToken(value) {
|
|
18
|
+
if (value.startsWith('*'))
|
|
19
|
+
return '*';
|
|
20
|
+
return (/^[a-z_-]+/i.exec(value)?.[0] ?? value).toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
export function parseRobots(text) {
|
|
23
|
+
const groups = [];
|
|
24
|
+
const sitemaps = [];
|
|
25
|
+
let current = null;
|
|
26
|
+
// 연속된 User-agent 줄은 한 그룹을 공유한다. 규칙이 나온 뒤의 User-agent 는 새 그룹.
|
|
27
|
+
let collectingAgents = false;
|
|
28
|
+
for (const raw of (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text).split(/\r\n|\r|\n/)) {
|
|
29
|
+
const hash = raw.indexOf('#');
|
|
30
|
+
const line = (hash === -1 ? raw : raw.slice(0, hash)).trim();
|
|
31
|
+
const sep = line.indexOf(':');
|
|
32
|
+
if (sep <= 0)
|
|
33
|
+
continue;
|
|
34
|
+
const field = line.slice(0, sep).trim().toLowerCase();
|
|
35
|
+
const value = line.slice(sep + 1).trim();
|
|
36
|
+
if (field === 'sitemap') {
|
|
37
|
+
if (value)
|
|
38
|
+
sitemaps.push(value);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (field === 'user-agent') {
|
|
42
|
+
if (!current || !collectingAgents) {
|
|
43
|
+
current = { agents: [], rules: [] };
|
|
44
|
+
groups.push(current);
|
|
45
|
+
}
|
|
46
|
+
current.agents.push(agentToken(value));
|
|
47
|
+
collectingAgents = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (field === 'allow' || field === 'disallow') {
|
|
51
|
+
collectingAgents = false;
|
|
52
|
+
if (!current || !value)
|
|
53
|
+
continue; // 그룹 밖 규칙, 빈 Disallow 는 무시
|
|
54
|
+
current.rules.push({ allow: field === 'allow', pattern: value });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { groups, sitemaps };
|
|
58
|
+
}
|
|
59
|
+
/** 비ASCII 는 UTF-8 퍼센트 인코딩, 기존 `%xx` 는 대문자 16진수로 — 패턴과 경로를 같은 표기로 맞춘다. */
|
|
60
|
+
export function normalizeRobotsPath(value) {
|
|
61
|
+
return value
|
|
62
|
+
.replace(/%[0-9a-f]{2}/gi, (m) => m.toUpperCase())
|
|
63
|
+
.replace(/[\u0080-]+/g, (chunk) => {
|
|
64
|
+
try {
|
|
65
|
+
return encodeURIComponent(chunk);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return chunk; // 짝 없는 서로게이트 — 그대로 둔다
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* `*`(0자 이상)와 닫는 `$`(끝 고정)만 있는 와일드카드 매칭. 앞쪽은 항상 고정(접두어 매칭).
|
|
74
|
+
* 투 포인터 백트래킹이라 최악 O(패턴 × 경로) — 정규식처럼 지수적으로 터지지 않는다.
|
|
75
|
+
*/
|
|
76
|
+
export function robotsPatternMatches(pattern, path) {
|
|
77
|
+
const anchored = pattern.endsWith('$');
|
|
78
|
+
// `$` 가 없으면 접두어 매칭 = 끝에 `*` 가 붙은 것과 같다.
|
|
79
|
+
const p = anchored ? pattern.slice(0, -1) : `${pattern}*`;
|
|
80
|
+
let i = 0;
|
|
81
|
+
let j = 0;
|
|
82
|
+
let star = -1;
|
|
83
|
+
let mark = 0;
|
|
84
|
+
while (i < path.length) {
|
|
85
|
+
if (j < p.length && p[j] === '*') {
|
|
86
|
+
star = j++;
|
|
87
|
+
mark = i;
|
|
88
|
+
}
|
|
89
|
+
else if (j < p.length && p[j] === path[i]) {
|
|
90
|
+
i++;
|
|
91
|
+
j++;
|
|
92
|
+
}
|
|
93
|
+
else if (star !== -1) {
|
|
94
|
+
j = star + 1;
|
|
95
|
+
i = ++mark;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
while (j < p.length && p[j] === '*')
|
|
102
|
+
j++;
|
|
103
|
+
return j === p.length;
|
|
104
|
+
}
|
|
105
|
+
/** `path` 는 경로 + 쿼리(`/a?b=1`). 크롤러 토큰은 소문자로 비교한다. */
|
|
106
|
+
export function isAllowed(robots, path, crawler = NAVER_CRAWLER_TOKEN) {
|
|
107
|
+
const token = crawler.toLowerCase();
|
|
108
|
+
const own = robots.groups.filter((g) => g.agents.includes(token));
|
|
109
|
+
const wildcard = robots.groups.filter((g) => g.agents.includes('*'));
|
|
110
|
+
const [groups, group] = own.length > 0
|
|
111
|
+
? [own, 'crawler']
|
|
112
|
+
: wildcard.length > 0 ? [wildcard, 'wildcard'] : [[], 'none'];
|
|
113
|
+
const target = normalizeRobotsPath(path);
|
|
114
|
+
let best;
|
|
115
|
+
let bestLength = -1;
|
|
116
|
+
for (const rule of groups.flatMap((g) => g.rules)) {
|
|
117
|
+
const pattern = normalizeRobotsPath(rule.pattern);
|
|
118
|
+
if (!robotsPatternMatches(pattern, target))
|
|
119
|
+
continue;
|
|
120
|
+
if (pattern.length > bestLength || (pattern.length === bestLength && rule.allow && !best?.allow)) {
|
|
121
|
+
best = rule;
|
|
122
|
+
bestLength = pattern.length;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { allowed: best ? best.allow : true, group, ...(best ? { matchedRule: best } : {}) };
|
|
126
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { type RobotsVerdict } from './robots.js';
|
|
2
|
+
/**
|
|
3
|
+
* 네이버 서치어드바이저 — 검색 노출·색인 도구.
|
|
4
|
+
*
|
|
5
|
+
* 서치어드바이저에는 **공개 웹마스터 API 가 없다**(사이트 등록·사이트맵/RSS 제출·리포트는 콘솔 전용).
|
|
6
|
+
* 자동화할 수 있는 면은 두 가지뿐이라 그것만 다룬다:
|
|
7
|
+
* - IndexNow: 네이버가 참여 엔진이다. 새/수정/삭제 URL 을 즉시 알린다. 인증 = 사이트에 올린 키 파일.
|
|
8
|
+
* - 크롤러 관점 점검: 네이버 검색로봇(Yeti)이 보는 robots.txt·상태 코드·리다이렉트·메타 태그를 확인한다.
|
|
9
|
+
* 어느 쪽도 `~/.mimi-seed/` 자격증명을 쓰지 않는다.
|
|
10
|
+
*
|
|
11
|
+
* 점검 대상 사이트의 응답(HTML·robots.txt·헤더)은 **적대적 입력**으로 다룬다: 본문은 상한까지만 읽고,
|
|
12
|
+
* 파싱은 선형 스캔으로만 한다. 정규식 백트래킹이나 무한 버퍼링 한 번이면 stdio 서버 전체가 멈춘다.
|
|
13
|
+
*/
|
|
14
|
+
/** 네이버가 공개한 검색로봇 User-Agent. UA 로 봇을 막는 서버를 잡아내려고 그대로 보낸다. */
|
|
15
|
+
export declare const YETI_USER_AGENT = "Mozilla/5.0 (compatible; Yeti/1.1; +https://naver.me/spd)";
|
|
16
|
+
/** indexnow.org/searchengines.json 에 등록된 네이버 엔드포인트. */
|
|
17
|
+
export declare const NAVER_INDEXNOW_ENDPOINT = "https://searchadvisor.naver.com/indexnow";
|
|
18
|
+
/** IndexNow 프로토콜: POST 1회 최대 URL 수. */
|
|
19
|
+
export declare const INDEXNOW_MAX_URLS = 10000;
|
|
20
|
+
/** IndexNow 프로토콜: 8–128자, 영문·숫자·대시. */
|
|
21
|
+
export declare const INDEXNOW_KEY_PATTERN: RegExp;
|
|
22
|
+
/** RFC 9309: 크롤러는 robots.txt 를 최소 500 KiB 까지 해석해야 한다. 그 이상은 버린다. */
|
|
23
|
+
export declare const ROBOTS_MAX_BYTES: number;
|
|
24
|
+
/** HTML 은 이만큼만 읽는다 — head·본문 추정에는 충분하고 메모리는 묶인다. */
|
|
25
|
+
export declare const HTML_MAX_BYTES: number;
|
|
26
|
+
/**
|
|
27
|
+
* 응답 본문을 `maxBytes` 까지만 읽고 나머지는 끊는다. 인코딩은 content-type 의 charset
|
|
28
|
+
* (EUC-KR 등 레거시 한국어 페이지 포함), 모르면 UTF-8. BOM 은 TextDecoder 가 벗긴다.
|
|
29
|
+
*/
|
|
30
|
+
export declare function readCapped(res: Response, maxBytes: number): Promise<{
|
|
31
|
+
text: string;
|
|
32
|
+
truncated: boolean;
|
|
33
|
+
}>;
|
|
34
|
+
export interface PageHead {
|
|
35
|
+
title?: string;
|
|
36
|
+
description?: string;
|
|
37
|
+
canonical?: string;
|
|
38
|
+
/** `<base href>` — 상대 canonical 을 풀 때 쓴다. */
|
|
39
|
+
base?: string;
|
|
40
|
+
lang?: string;
|
|
41
|
+
og: {
|
|
42
|
+
title?: string;
|
|
43
|
+
description?: string;
|
|
44
|
+
image?: string;
|
|
45
|
+
url?: string;
|
|
46
|
+
};
|
|
47
|
+
/** `<meta name="robots">` 와 `<meta name="yeti">` 의 지시어(소문자). */
|
|
48
|
+
robots: string[];
|
|
49
|
+
naverSiteVerification: boolean;
|
|
50
|
+
/** script/style 을 뺀 원본 HTML 의 보이는 글자 수. */
|
|
51
|
+
textLength: number;
|
|
52
|
+
scriptCount: number;
|
|
53
|
+
}
|
|
54
|
+
export declare function decodeEntities(value: string): string;
|
|
55
|
+
/** 속성 문자열 파서. HTML 처럼 같은 이름은 **처음 것**이 이긴다. */
|
|
56
|
+
export declare function parseAttrs(src: string): Record<string, string>;
|
|
57
|
+
export declare function parseHead(html: string): PageHead;
|
|
58
|
+
export interface XRobots {
|
|
59
|
+
/** 이 크롤러에 확실히 적용되는 지시어. */
|
|
60
|
+
applies: string[];
|
|
61
|
+
/**
|
|
62
|
+
* 다른 봇 접두어(`googlebot: …`) 뒤에 콤마로 이어진 지시어. 같은 헤더의 연속이면 그 봇 전용이지만,
|
|
63
|
+
* 헤더가 여러 개면 fetch 가 `, ` 로 합쳐 버려 **접두어 없는 별도 헤더**였을 수도 있다 — 구분할 수 없다.
|
|
64
|
+
*/
|
|
65
|
+
ambiguous: string[];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* `X-Robots-Tag` 에서 이 크롤러에 적용되는 지시어를 뽑는다.
|
|
69
|
+
* UA 접두어가 없는 지시어는 모두에게, `yeti: …` 는 Yeti 에게, `googlebot: …` 은 건너뛴다.
|
|
70
|
+
*/
|
|
71
|
+
export declare function xRobotsDirectives(header: string | null, crawler?: string): XRobots;
|
|
72
|
+
/** HTTP `Link: <…>; rel="canonical"` 헤더의 대상. */
|
|
73
|
+
export declare function linkHeaderCanonical(header: string | null): string | undefined;
|
|
74
|
+
export type IssueLevel = 'error' | 'warn' | 'info';
|
|
75
|
+
export interface PageIssue {
|
|
76
|
+
level: IssueLevel;
|
|
77
|
+
code: string;
|
|
78
|
+
message: string;
|
|
79
|
+
}
|
|
80
|
+
export interface RedirectHop {
|
|
81
|
+
url: string;
|
|
82
|
+
status: number;
|
|
83
|
+
location: string;
|
|
84
|
+
}
|
|
85
|
+
export interface PageCheck {
|
|
86
|
+
url: string;
|
|
87
|
+
finalUrl: string;
|
|
88
|
+
status: number;
|
|
89
|
+
contentType: string | null;
|
|
90
|
+
redirects: RedirectHop[];
|
|
91
|
+
robotsTxt: {
|
|
92
|
+
url: string;
|
|
93
|
+
status: number | null;
|
|
94
|
+
verdict: RobotsVerdict | null;
|
|
95
|
+
sitemaps: string[];
|
|
96
|
+
};
|
|
97
|
+
directives: {
|
|
98
|
+
meta: string[];
|
|
99
|
+
header: string[];
|
|
100
|
+
headerAmbiguous: string[];
|
|
101
|
+
};
|
|
102
|
+
head: PageHead | null;
|
|
103
|
+
issues: PageIssue[];
|
|
104
|
+
summary: Record<IssueLevel, number>;
|
|
105
|
+
}
|
|
106
|
+
/** 네이버 검색로봇(Yeti) 관점에서 URL 1개를 점검한다. 쓰기 없음. */
|
|
107
|
+
export declare function checkPage(input: string): Promise<PageCheck>;
|
|
108
|
+
export interface IndexNowInput {
|
|
109
|
+
urls: string[];
|
|
110
|
+
key: string;
|
|
111
|
+
/** 키 파일이 사이트 루트(`/<key>.txt`)가 아닐 때만. */
|
|
112
|
+
keyLocation?: string;
|
|
113
|
+
}
|
|
114
|
+
export interface IndexNowResult {
|
|
115
|
+
endpoint: string;
|
|
116
|
+
status: number;
|
|
117
|
+
host: string;
|
|
118
|
+
keyLocation: string;
|
|
119
|
+
submitted: number;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* 네이버 IndexNow 로 URL 변경을 알린다.
|
|
123
|
+
*
|
|
124
|
+
* 보내기 전에 키 파일을 직접 받아 key 와 같은지 확인한다 — 틀린 키는 엔드포인트가 403 만 돌려주고,
|
|
125
|
+
* 같은 호스트에 틀린 키로 반복 제출하면 스팸 판정 위험만 커진다. 같은 URL 재제출은 안전하다.
|
|
126
|
+
*/
|
|
127
|
+
export declare function submitIndexNow({ urls, key, keyLocation }: IndexNowInput): Promise<IndexNowResult>;
|
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
import { fetchWithTimeout } from '../lib/http.js';
|
|
2
|
+
import { encodePathSegment } from '../lib/url-path.js';
|
|
3
|
+
import { isAllowed, parseRobots } from './robots.js';
|
|
4
|
+
/**
|
|
5
|
+
* 네이버 서치어드바이저 — 검색 노출·색인 도구.
|
|
6
|
+
*
|
|
7
|
+
* 서치어드바이저에는 **공개 웹마스터 API 가 없다**(사이트 등록·사이트맵/RSS 제출·리포트는 콘솔 전용).
|
|
8
|
+
* 자동화할 수 있는 면은 두 가지뿐이라 그것만 다룬다:
|
|
9
|
+
* - IndexNow: 네이버가 참여 엔진이다. 새/수정/삭제 URL 을 즉시 알린다. 인증 = 사이트에 올린 키 파일.
|
|
10
|
+
* - 크롤러 관점 점검: 네이버 검색로봇(Yeti)이 보는 robots.txt·상태 코드·리다이렉트·메타 태그를 확인한다.
|
|
11
|
+
* 어느 쪽도 `~/.mimi-seed/` 자격증명을 쓰지 않는다.
|
|
12
|
+
*
|
|
13
|
+
* 점검 대상 사이트의 응답(HTML·robots.txt·헤더)은 **적대적 입력**으로 다룬다: 본문은 상한까지만 읽고,
|
|
14
|
+
* 파싱은 선형 스캔으로만 한다. 정규식 백트래킹이나 무한 버퍼링 한 번이면 stdio 서버 전체가 멈춘다.
|
|
15
|
+
*/
|
|
16
|
+
/** 네이버가 공개한 검색로봇 User-Agent. UA 로 봇을 막는 서버를 잡아내려고 그대로 보낸다. */
|
|
17
|
+
export const YETI_USER_AGENT = 'Mozilla/5.0 (compatible; Yeti/1.1; +https://naver.me/spd)';
|
|
18
|
+
/** Yeti UA 가 거절됐을 때 "UA 차단인가"를 가르는 비교용 브라우저 UA. */
|
|
19
|
+
const BROWSER_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36';
|
|
20
|
+
/** indexnow.org/searchengines.json 에 등록된 네이버 엔드포인트. */
|
|
21
|
+
export const NAVER_INDEXNOW_ENDPOINT = 'https://searchadvisor.naver.com/indexnow';
|
|
22
|
+
/** IndexNow 프로토콜: POST 1회 최대 URL 수. */
|
|
23
|
+
export const INDEXNOW_MAX_URLS = 10_000;
|
|
24
|
+
/** IndexNow 프로토콜: 8–128자, 영문·숫자·대시. */
|
|
25
|
+
export const INDEXNOW_KEY_PATTERN = /^[A-Za-z0-9-]{8,128}$/;
|
|
26
|
+
const MAX_REDIRECTS = 5;
|
|
27
|
+
/** RFC 9309: 크롤러는 robots.txt 를 최소 500 KiB 까지 해석해야 한다. 그 이상은 버린다. */
|
|
28
|
+
export const ROBOTS_MAX_BYTES = 500 * 1024;
|
|
29
|
+
/** HTML 은 이만큼만 읽는다 — head·본문 추정에는 충분하고 메모리는 묶인다. */
|
|
30
|
+
export const HTML_MAX_BYTES = 4 * 1024 * 1024;
|
|
31
|
+
/** 원본 HTML 의 보이는 글자 수가 이보다 적으면 JS 렌더링 의존으로 본다. */
|
|
32
|
+
const THIN_TEXT_CHARS = 200;
|
|
33
|
+
function parseHttpUrl(value, label) {
|
|
34
|
+
let url;
|
|
35
|
+
try {
|
|
36
|
+
url = new URL(value.trim());
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw new Error(`${label}: 올바른 절대 URL 이 아닙니다 — ${JSON.stringify(value)}`);
|
|
40
|
+
}
|
|
41
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
42
|
+
throw new Error(`${label}: http(s) URL 만 받습니다 — ${JSON.stringify(value)}`);
|
|
43
|
+
}
|
|
44
|
+
url.hash = '';
|
|
45
|
+
return url;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* 응답 본문을 `maxBytes` 까지만 읽고 나머지는 끊는다. 인코딩은 content-type 의 charset
|
|
49
|
+
* (EUC-KR 등 레거시 한국어 페이지 포함), 모르면 UTF-8. BOM 은 TextDecoder 가 벗긴다.
|
|
50
|
+
*/
|
|
51
|
+
export async function readCapped(res, maxBytes) {
|
|
52
|
+
const reader = res.body?.getReader();
|
|
53
|
+
if (!reader)
|
|
54
|
+
return { text: '', truncated: false };
|
|
55
|
+
const chunks = [];
|
|
56
|
+
let size = 0;
|
|
57
|
+
let truncated = false;
|
|
58
|
+
for (;;) {
|
|
59
|
+
const { done, value } = await reader.read();
|
|
60
|
+
if (done)
|
|
61
|
+
break;
|
|
62
|
+
if (size + value.byteLength > maxBytes) {
|
|
63
|
+
chunks.push(value.subarray(0, maxBytes - size));
|
|
64
|
+
size = maxBytes;
|
|
65
|
+
truncated = true;
|
|
66
|
+
await reader.cancel();
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
chunks.push(value);
|
|
70
|
+
size += value.byteLength;
|
|
71
|
+
}
|
|
72
|
+
const bytes = new Uint8Array(size);
|
|
73
|
+
let offset = 0;
|
|
74
|
+
for (const chunk of chunks) {
|
|
75
|
+
bytes.set(chunk, offset);
|
|
76
|
+
offset += chunk.byteLength;
|
|
77
|
+
}
|
|
78
|
+
const charset = /charset\s*=\s*"?([^";\s]+)/i.exec(res.headers.get('content-type') ?? '')?.[1];
|
|
79
|
+
let decoder;
|
|
80
|
+
try {
|
|
81
|
+
decoder = new TextDecoder(charset ?? 'utf-8');
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
decoder = new TextDecoder('utf-8');
|
|
85
|
+
}
|
|
86
|
+
return { text: decoder.decode(bytes), truncated };
|
|
87
|
+
}
|
|
88
|
+
const NAMED_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
|
|
89
|
+
function codePoint(value, raw) {
|
|
90
|
+
return Number.isInteger(value) && value >= 0 && value <= 0x10ffff ? String.fromCodePoint(value) : raw;
|
|
91
|
+
}
|
|
92
|
+
export function decodeEntities(value) {
|
|
93
|
+
return value.replace(/&(#x[0-9a-f]{1,8}|#\d{1,10}|[a-z]{2,6});/gi, (raw, body) => {
|
|
94
|
+
if (body[0] === '#') {
|
|
95
|
+
const hex = body[1] === 'x' || body[1] === 'X';
|
|
96
|
+
return codePoint(parseInt(body.slice(hex ? 2 : 1), hex ? 16 : 10), raw);
|
|
97
|
+
}
|
|
98
|
+
return NAMED_ENTITIES[body.toLowerCase()] ?? raw;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const isSpace = (c) => c === ' ' || c === '\n' || c === '\t' || c === '\r' || c === '\f';
|
|
102
|
+
/** 속성 문자열 파서. HTML 처럼 같은 이름은 **처음 것**이 이긴다. */
|
|
103
|
+
export function parseAttrs(src) {
|
|
104
|
+
const out = {};
|
|
105
|
+
const n = src.length;
|
|
106
|
+
let i = 0;
|
|
107
|
+
while (i < n) {
|
|
108
|
+
while (i < n && (isSpace(src[i]) || src[i] === '/'))
|
|
109
|
+
i++;
|
|
110
|
+
const nameStart = i;
|
|
111
|
+
while (i < n && !isSpace(src[i]) && src[i] !== '=' && src[i] !== '/')
|
|
112
|
+
i++;
|
|
113
|
+
if (i === nameStart) {
|
|
114
|
+
i++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const name = src.slice(nameStart, i).toLowerCase();
|
|
118
|
+
let j = i;
|
|
119
|
+
while (j < n && isSpace(src[j]))
|
|
120
|
+
j++;
|
|
121
|
+
let value = '';
|
|
122
|
+
if (src[j] === '=') {
|
|
123
|
+
j++;
|
|
124
|
+
while (j < n && isSpace(src[j]))
|
|
125
|
+
j++;
|
|
126
|
+
const quote = src[j];
|
|
127
|
+
if (quote === '"' || quote === "'") {
|
|
128
|
+
const end = src.indexOf(quote, j + 1);
|
|
129
|
+
value = src.slice(j + 1, end === -1 ? n : end);
|
|
130
|
+
i = end === -1 ? n : end + 1;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
const start = j;
|
|
134
|
+
while (j < n && !isSpace(src[j]))
|
|
135
|
+
j++;
|
|
136
|
+
value = src.slice(start, j);
|
|
137
|
+
i = j;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (!(name in out))
|
|
141
|
+
out[name] = decodeEntities(value);
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
/** 내용을 태그로 해석하지 않는 요소 — 닫는 태그까지 통째로 건너뛴다. */
|
|
146
|
+
const RAW_TEXT = new Set(['script', 'style', 'noscript', 'template', 'textarea', 'title']);
|
|
147
|
+
/**
|
|
148
|
+
* 한 번 훑는 토크나이저. 모든 탐색은 앞으로만 가는 indexOf 라 입력 길이에 선형이다.
|
|
149
|
+
* 주석·script 는 등장 순서대로 건너뛰므로, script 안의 `<!--` 가 뒤쪽 진짜 메타를 삼키지 않는다.
|
|
150
|
+
*/
|
|
151
|
+
function tokenize(html) {
|
|
152
|
+
const lower = html.toLowerCase();
|
|
153
|
+
const tags = [];
|
|
154
|
+
const allText = [];
|
|
155
|
+
const bodyText = [];
|
|
156
|
+
let inBody = false;
|
|
157
|
+
let title;
|
|
158
|
+
let scriptCount = 0;
|
|
159
|
+
const n = html.length;
|
|
160
|
+
let i = 0;
|
|
161
|
+
const pushText = (t) => {
|
|
162
|
+
allText.push(t);
|
|
163
|
+
if (inBody)
|
|
164
|
+
bodyText.push(t);
|
|
165
|
+
};
|
|
166
|
+
while (i < n) {
|
|
167
|
+
const lt = html.indexOf('<', i);
|
|
168
|
+
if (lt === -1) {
|
|
169
|
+
pushText(html.slice(i));
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
if (lt > i)
|
|
173
|
+
pushText(html.slice(i, lt));
|
|
174
|
+
if (html.startsWith('<!--', lt)) {
|
|
175
|
+
const end = html.indexOf('-->', lt + 4);
|
|
176
|
+
i = end === -1 ? n : end + 3;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const head = /^<(\/?)([a-zA-Z][a-zA-Z0-9:-]*)/.exec(html.slice(lt, lt + 64));
|
|
180
|
+
if (!head) {
|
|
181
|
+
if (html[lt + 1] === '!' || html[lt + 1] === '?') {
|
|
182
|
+
const end = html.indexOf('>', lt);
|
|
183
|
+
i = end === -1 ? n : end + 1;
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
pushText('<');
|
|
187
|
+
i = lt + 1;
|
|
188
|
+
}
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
// 태그 끝: 따옴표 밖의 첫 `>`. 따옴표는 `=` 바로 뒤에서만 값의 시작이다.
|
|
192
|
+
let j = lt + head[0].length;
|
|
193
|
+
let quote = null;
|
|
194
|
+
let prev = '';
|
|
195
|
+
while (j < n) {
|
|
196
|
+
const c = html[j];
|
|
197
|
+
if (quote) {
|
|
198
|
+
if (c === quote)
|
|
199
|
+
quote = null;
|
|
200
|
+
}
|
|
201
|
+
else if ((c === '"' || c === "'") && prev === '=') {
|
|
202
|
+
quote = c;
|
|
203
|
+
}
|
|
204
|
+
else if (c === '>') {
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
if (!isSpace(c))
|
|
208
|
+
prev = c;
|
|
209
|
+
j++;
|
|
210
|
+
}
|
|
211
|
+
if (j >= n)
|
|
212
|
+
break; // 닫히지 않은 태그 — 나머지는 해석하지 않는다
|
|
213
|
+
const name = head[2].toLowerCase();
|
|
214
|
+
const closing = head[1] === '/';
|
|
215
|
+
i = j + 1;
|
|
216
|
+
if (closing)
|
|
217
|
+
continue;
|
|
218
|
+
tags.push({ name, attrs: parseAttrs(html.slice(lt + head[0].length, j)) });
|
|
219
|
+
if (name === 'body')
|
|
220
|
+
inBody = true;
|
|
221
|
+
if (RAW_TEXT.has(name)) {
|
|
222
|
+
if (name === 'script')
|
|
223
|
+
scriptCount++;
|
|
224
|
+
const close = lower.indexOf("</" + name, i);
|
|
225
|
+
const end = close === -1 ? n : close;
|
|
226
|
+
if (name === 'title' && title === undefined)
|
|
227
|
+
title = html.slice(i, end);
|
|
228
|
+
i = end;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { tags, title, bodyText, allText, scriptCount };
|
|
232
|
+
}
|
|
233
|
+
function splitDirectives(content) {
|
|
234
|
+
return content.split(',').map((d) => d.trim().toLowerCase()).filter(Boolean);
|
|
235
|
+
}
|
|
236
|
+
export function parseHead(html) {
|
|
237
|
+
const { tags, title, bodyText, allText, scriptCount } = tokenize(html);
|
|
238
|
+
const head = { og: {}, robots: [], naverSiteVerification: false, textLength: 0, scriptCount };
|
|
239
|
+
const set = (key, value) => {
|
|
240
|
+
if (head[key] === undefined && value)
|
|
241
|
+
head[key] = value;
|
|
242
|
+
};
|
|
243
|
+
const setOg = (key, value) => {
|
|
244
|
+
if (head.og[key] === undefined && value)
|
|
245
|
+
head.og[key] = value;
|
|
246
|
+
};
|
|
247
|
+
if (title !== undefined)
|
|
248
|
+
head.title = decodeEntities(title).replace(/\s+/g, ' ').trim() || undefined;
|
|
249
|
+
for (const { name, attrs } of tags) {
|
|
250
|
+
if (name === 'html')
|
|
251
|
+
set('lang', attrs.lang?.trim());
|
|
252
|
+
else if (name === 'base')
|
|
253
|
+
set('base', attrs.href?.trim());
|
|
254
|
+
else if (name === 'link') {
|
|
255
|
+
if (attrs.rel?.toLowerCase().split(/\s+/).includes('canonical'))
|
|
256
|
+
set('canonical', attrs.href?.trim());
|
|
257
|
+
}
|
|
258
|
+
else if (name === 'meta' && attrs.content !== undefined) {
|
|
259
|
+
const content = attrs.content.trim();
|
|
260
|
+
// `<meta name="description" property="og:description" content="…">` 처럼 한 태그가 둘 다일 수 있다.
|
|
261
|
+
for (const key of new Set([attrs.name, attrs.property].filter(Boolean).map((k) => k.toLowerCase()))) {
|
|
262
|
+
if (key === 'description')
|
|
263
|
+
set('description', content);
|
|
264
|
+
else if (key === 'robots' || key === 'yeti')
|
|
265
|
+
head.robots.push(...splitDirectives(content));
|
|
266
|
+
else if (key === 'naver-site-verification')
|
|
267
|
+
head.naverSiteVerification ||= content.length > 0;
|
|
268
|
+
else if (key === 'og:title')
|
|
269
|
+
setOg('title', content);
|
|
270
|
+
else if (key === 'og:description')
|
|
271
|
+
setOg('description', content);
|
|
272
|
+
else if (key === 'og:image')
|
|
273
|
+
setOg('image', content);
|
|
274
|
+
else if (key === 'og:url')
|
|
275
|
+
setOg('url', content);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const text = (tags.some((t) => t.name === 'body') ? bodyText : allText).join(' ');
|
|
280
|
+
head.textLength = decodeEntities(text).replace(/\s+/g, ' ').trim().length;
|
|
281
|
+
return head;
|
|
282
|
+
}
|
|
283
|
+
/** 값이 있는 지시어(`max-snippet: 50`)의 이름 — `googlebot: noindex` 의 UA 접두어와 구분한다. */
|
|
284
|
+
const VALUE_DIRECTIVES = new Set(['unavailable_after', 'max-snippet', 'max-image-preview', 'max-video-preview']);
|
|
285
|
+
/**
|
|
286
|
+
* `X-Robots-Tag` 에서 이 크롤러에 적용되는 지시어를 뽑는다.
|
|
287
|
+
* UA 접두어가 없는 지시어는 모두에게, `yeti: …` 는 Yeti 에게, `googlebot: …` 은 건너뛴다.
|
|
288
|
+
*/
|
|
289
|
+
export function xRobotsDirectives(header, crawler = 'yeti') {
|
|
290
|
+
const out = { applies: [], ambiguous: [] };
|
|
291
|
+
if (!header)
|
|
292
|
+
return out;
|
|
293
|
+
let scope = null;
|
|
294
|
+
for (const part of header.split(',')) {
|
|
295
|
+
let directive = part.trim();
|
|
296
|
+
const scoped = /^([a-z0-9_-]+)\s*:\s*(.*)$/i.exec(directive);
|
|
297
|
+
let explicit = false;
|
|
298
|
+
if (scoped && !VALUE_DIRECTIVES.has(scoped[1].toLowerCase())) {
|
|
299
|
+
scope = scoped[1].toLowerCase();
|
|
300
|
+
directive = scoped[2].trim();
|
|
301
|
+
explicit = true;
|
|
302
|
+
}
|
|
303
|
+
if (!directive)
|
|
304
|
+
continue;
|
|
305
|
+
const d = directive.toLowerCase();
|
|
306
|
+
if (scope === null || scope === crawler)
|
|
307
|
+
out.applies.push(d);
|
|
308
|
+
else if (!explicit)
|
|
309
|
+
out.ambiguous.push(d);
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
/** HTTP `Link: <…>; rel="canonical"` 헤더의 대상. */
|
|
314
|
+
export function linkHeaderCanonical(header) {
|
|
315
|
+
if (!header)
|
|
316
|
+
return undefined;
|
|
317
|
+
for (const m of header.matchAll(/<([^>]*)>([^,<]*)/g)) {
|
|
318
|
+
if (/\brel\s*=\s*"?[^";,]*\bcanonical\b/i.test(m[2]))
|
|
319
|
+
return m[1].trim();
|
|
320
|
+
}
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
async function fetchRobots(origin) {
|
|
324
|
+
const url = `${origin}/robots.txt`;
|
|
325
|
+
try {
|
|
326
|
+
const res = await fetchWithTimeout(url, { headers: { 'user-agent': YETI_USER_AGENT } });
|
|
327
|
+
if (res.ok) {
|
|
328
|
+
const { text } = await readCapped(res, ROBOTS_MAX_BYTES);
|
|
329
|
+
return { url, status: res.status, unreachable: false, parsed: parseRobots(text) };
|
|
330
|
+
}
|
|
331
|
+
await res.body?.cancel();
|
|
332
|
+
// 4xx = robots.txt 없음 → 전부 허용. 5xx·429 = 접근 불가 → 전부 차단으로 취급.
|
|
333
|
+
return {
|
|
334
|
+
url, status: res.status, unreachable: res.status >= 500 || res.status === 429, parsed: { groups: [], sitemaps: [] },
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
catch (err) {
|
|
338
|
+
return {
|
|
339
|
+
url, status: null, unreachable: true, parsed: { groups: [], sitemaps: [] },
|
|
340
|
+
error: err instanceof Error ? err.message : String(err),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/** 네이버 검색로봇(Yeti) 관점에서 URL 1개를 점검한다. 쓰기 없음. */
|
|
345
|
+
export async function checkPage(input) {
|
|
346
|
+
const start = parseHttpUrl(input, 'url');
|
|
347
|
+
const issues = [];
|
|
348
|
+
const add = (level, code, message) => issues.push({ level, code, message });
|
|
349
|
+
const accept = 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.8';
|
|
350
|
+
const headers = { 'user-agent': YETI_USER_AGENT, accept };
|
|
351
|
+
// 리다이렉트를 직접 따라가며 체인을 남긴다 — 302 로 옮긴 페이지는 원래 URL 이 색인에 남기 쉽다.
|
|
352
|
+
const redirects = [];
|
|
353
|
+
let current = start;
|
|
354
|
+
let res;
|
|
355
|
+
/** 체인이 끝나지 않고 멈췄다(순환·깨진 Location) — 마지막 응답은 3xx 라 읽을 페이지가 없다. */
|
|
356
|
+
let stopped = false;
|
|
357
|
+
for (;;) {
|
|
358
|
+
res = await fetchWithTimeout(current, { headers, redirect: 'manual' });
|
|
359
|
+
const location = res.headers.get('location');
|
|
360
|
+
if (res.status < 300 || res.status >= 400 || !location)
|
|
361
|
+
break;
|
|
362
|
+
await res.body?.cancel();
|
|
363
|
+
let next;
|
|
364
|
+
try {
|
|
365
|
+
next = new URL(location, current);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
stopped = true;
|
|
369
|
+
add('error', 'redirect_invalid', `${current.href} 의 Location 헤더가 URL 이 아니다: ${JSON.stringify(location)}`);
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
next.hash = '';
|
|
373
|
+
redirects.push({ url: current.href, status: res.status, location: next.href });
|
|
374
|
+
if (redirects.length > MAX_REDIRECTS || redirects.some((hop) => hop.url === next.href)) {
|
|
375
|
+
stopped = true;
|
|
376
|
+
add('error', 'redirect_loop', `리다이렉트가 ${redirects.length}번 이어지거나 순환한다 — 크롤러는 중간에 포기한다.`);
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
current = next;
|
|
380
|
+
}
|
|
381
|
+
const temporary = redirects.filter((hop) => hop.status !== 301 && hop.status !== 308);
|
|
382
|
+
if (temporary.length > 0) {
|
|
383
|
+
add('warn', 'redirect_temporary', `임시 리다이렉트(${temporary.map((h) => h.status).join(', ')})가 있다 — 영구 이전이면 301/308 로 바꿔야 새 URL 이 색인된다.`);
|
|
384
|
+
}
|
|
385
|
+
if (current.protocol !== 'https:')
|
|
386
|
+
add('warn', 'not_https', '최종 URL 이 https 가 아니다.');
|
|
387
|
+
// robots.txt — 시작 URL, 중간 경유지, 최종 URL 모두 Yeti 가 긁을 수 있어야 끝까지 따라간다.
|
|
388
|
+
const robotsCache = new Map();
|
|
389
|
+
const robotsFor = (origin) => {
|
|
390
|
+
let pending = robotsCache.get(origin);
|
|
391
|
+
if (!pending) {
|
|
392
|
+
pending = fetchRobots(origin);
|
|
393
|
+
robotsCache.set(origin, pending);
|
|
394
|
+
}
|
|
395
|
+
return pending;
|
|
396
|
+
};
|
|
397
|
+
const hops = new Map([[start.href, start]]);
|
|
398
|
+
for (const hop of redirects)
|
|
399
|
+
hops.set(hop.location, new URL(hop.location));
|
|
400
|
+
const finalRobots = await robotsFor(current.origin);
|
|
401
|
+
let finalVerdict = null;
|
|
402
|
+
const unreachableReported = new Set();
|
|
403
|
+
for (const target of hops.values()) {
|
|
404
|
+
const robots = await robotsFor(target.origin);
|
|
405
|
+
if (robots.unreachable) {
|
|
406
|
+
if (!unreachableReported.has(robots.url)) {
|
|
407
|
+
unreachableReported.add(robots.url);
|
|
408
|
+
add('error', 'robots_unreachable', `${robots.url} 를 읽지 못했다(${robots.status ?? robots.error}) — 크롤러는 이 경우 사이트 전체를 막힌 것으로 본다.`);
|
|
409
|
+
}
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const verdict = isAllowed(robots.parsed, target.pathname + target.search);
|
|
413
|
+
if (target.href === current.href)
|
|
414
|
+
finalVerdict = verdict;
|
|
415
|
+
if (!verdict.allowed) {
|
|
416
|
+
const via = verdict.group === 'crawler' ? 'User-agent: Yeti' : 'User-agent: *';
|
|
417
|
+
add('error', 'robots_blocked', `robots.txt 가 Yeti 의 ${target.href} 수집을 막는다 (${via} → Disallow: ${verdict.matchedRule?.pattern}).`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (!finalRobots.unreachable && finalRobots.parsed.sitemaps.length === 0) {
|
|
421
|
+
add('info', 'no_sitemap_in_robots', 'robots.txt 에 Sitemap: 선언이 없다. 서치어드바이저 콘솔(요청 → 사이트맵 제출)에도 따로 제출해 두는 게 좋다.');
|
|
422
|
+
}
|
|
423
|
+
if (res.status !== 200) {
|
|
424
|
+
add('error', 'http_status', `최종 응답이 ${res.status} 다 — 200 이 아니면 색인되지 않는다.`);
|
|
425
|
+
if (!stopped) {
|
|
426
|
+
// Yeti UA 만 막히는가? WAF 는 IP 가 네이버가 아닌 "가짜 Yeti" 를 거르기도 해서, 진짜 Yeti 는 통과할 수 있다.
|
|
427
|
+
try {
|
|
428
|
+
const probe = await fetchWithTimeout(current, { headers: { 'user-agent': BROWSER_USER_AGENT, accept }, redirect: 'manual' });
|
|
429
|
+
await probe.body?.cancel();
|
|
430
|
+
if (probe.status === 200) {
|
|
431
|
+
add('warn', 'ua_blocked', `Yeti UA 로는 ${res.status}, 브라우저 UA 로는 200 이다 — 서버/WAF 가 봇 UA 를 막거나, 네이버 IP 가 아닌 Yeti 를 거르는 것일 수 있다. ` +
|
|
432
|
+
'진짜 Yeti 의 수집 결과는 서치어드바이저 콘솔(수집 현황·웹 페이지 최적화)에서 확인해.');
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
// 비교 요청 실패는 진단을 바꾸지 않는다.
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const contentType = res.headers.get('content-type');
|
|
441
|
+
const xRobots = xRobotsDirectives(res.headers.get('x-robots-tag'));
|
|
442
|
+
let head = null;
|
|
443
|
+
if (stopped) {
|
|
444
|
+
// 마지막 3xx 의 본문은 이미 버렸다 — 파싱할 페이지가 없다.
|
|
445
|
+
}
|
|
446
|
+
else if (contentType && /html/i.test(contentType)) {
|
|
447
|
+
const { text, truncated } = await readCapped(res, HTML_MAX_BYTES);
|
|
448
|
+
head = parseHead(text);
|
|
449
|
+
if (truncated)
|
|
450
|
+
add('info', 'html_truncated', `HTML 이 ${HTML_MAX_BYTES / 1024 / 1024} MiB 를 넘어 앞부분만 점검했다.`);
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
await res.body?.cancel();
|
|
454
|
+
add('warn', 'not_html', `HTML 이 아니다(content-type: ${contentType ?? '없음'}) — 메타 태그 점검을 건너뛴다.`);
|
|
455
|
+
}
|
|
456
|
+
const blocks = (list) => list.includes('noindex') || list.includes('none');
|
|
457
|
+
const sources = [
|
|
458
|
+
...(blocks(xRobots.applies) ? ['X-Robots-Tag'] : []),
|
|
459
|
+
...(blocks(head?.robots ?? []) ? ['meta robots/yeti'] : []),
|
|
460
|
+
];
|
|
461
|
+
if (sources.length > 0)
|
|
462
|
+
add('error', 'noindex', `noindex 지시어가 있다 (${sources.join(', ')}) — 검색에 나오지 않는다.`);
|
|
463
|
+
else if (blocks(xRobots.ambiguous)) {
|
|
464
|
+
add('warn', 'noindex_ambiguous', `X-Robots-Tag 의 noindex 가 다른 봇 접두어 뒤에 이어져 있다(${res.headers.get('x-robots-tag')}). ` +
|
|
465
|
+
'X-Robots-Tag 헤더가 여러 줄이면 이 noindex 는 Yeti 에게도 적용된다 — 서버 설정을 확인해.');
|
|
466
|
+
}
|
|
467
|
+
if ([...xRobots.applies, ...(head?.robots ?? [])].includes('nofollow')) {
|
|
468
|
+
add('warn', 'nofollow', 'nofollow 지시어가 있다 — 이 페이지의 링크를 따라가지 않는다.');
|
|
469
|
+
}
|
|
470
|
+
if (head) {
|
|
471
|
+
const canonicalRaw = head.canonical ?? linkHeaderCanonical(res.headers.get('link'));
|
|
472
|
+
if (canonicalRaw) {
|
|
473
|
+
let base = current;
|
|
474
|
+
try {
|
|
475
|
+
if (head.base)
|
|
476
|
+
base = new URL(head.base, current);
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
// 깨진 <base href> 는 무시하고 문서 URL 기준으로 푼다.
|
|
480
|
+
}
|
|
481
|
+
let canonical = null;
|
|
482
|
+
try {
|
|
483
|
+
canonical = new URL(canonicalRaw, base);
|
|
484
|
+
canonical.hash = '';
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
add('warn', 'canonical_invalid', `canonical 값이 URL 이 아니다: ${JSON.stringify(canonicalRaw)}`);
|
|
488
|
+
}
|
|
489
|
+
if (canonical && canonical.href !== current.href) {
|
|
490
|
+
add('warn', 'canonical_mismatch', `정규 URL 이 다른 주소(${canonical.href})를 가리킨다 — 이 URL 대신 그쪽이 색인된다.`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
add('info', 'no_canonical', '<link rel="canonical"> 이 없다 — 파라미터 붙은 중복 URL 이 따로 색인될 수 있다.');
|
|
495
|
+
}
|
|
496
|
+
if (!head.title)
|
|
497
|
+
add('warn', 'no_title', '<title> 이 없거나 비었다.');
|
|
498
|
+
if (!head.description)
|
|
499
|
+
add('warn', 'no_description', '<meta name="description"> 이 없다 — 검색 결과 요약문으로 쓰인다.');
|
|
500
|
+
const og = head.og;
|
|
501
|
+
const missingOg = ['title', 'description', 'image'].filter((k) => !og[k]);
|
|
502
|
+
if (missingOg.length > 0) {
|
|
503
|
+
add('warn', 'og_missing', `Open Graph 태그가 빠졌다: ${missingOg.map((k) => `og:${k}`).join(', ')} — 서치어드바이저가 권장하는 제목·요약·대표 이미지 정보다.`);
|
|
504
|
+
}
|
|
505
|
+
if (head.textLength < THIN_TEXT_CHARS && head.scriptCount > 0) {
|
|
506
|
+
add('warn', 'thin_html', `원본 HTML 의 본문이 ${head.textLength}자뿐이다 — JS 로 그리는 페이지면 크롤러에 빈 문서로 보일 수 있다. 서버 렌더링(SSR/SSG)이 가장 안전하다.`);
|
|
507
|
+
}
|
|
508
|
+
if (!head.lang)
|
|
509
|
+
add('info', 'no_lang', '<html lang> 이 없다.');
|
|
510
|
+
if (!head.naverSiteVerification) {
|
|
511
|
+
add('info', 'no_site_verification', 'naver-site-verification 메타 태그가 없다 — HTML 파일 방식으로 소유 확인했다면 무시해도 된다.');
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
const summary = { error: 0, warn: 0, info: 0 };
|
|
515
|
+
for (const issue of issues)
|
|
516
|
+
summary[issue.level] += 1;
|
|
517
|
+
return {
|
|
518
|
+
url: start.href,
|
|
519
|
+
finalUrl: current.href,
|
|
520
|
+
status: res.status,
|
|
521
|
+
contentType,
|
|
522
|
+
redirects,
|
|
523
|
+
robotsTxt: {
|
|
524
|
+
url: finalRobots.url,
|
|
525
|
+
status: finalRobots.status,
|
|
526
|
+
verdict: finalVerdict,
|
|
527
|
+
sitemaps: finalRobots.parsed.sitemaps,
|
|
528
|
+
},
|
|
529
|
+
directives: { meta: head?.robots ?? [], header: xRobots.applies, headerAmbiguous: xRobots.ambiguous },
|
|
530
|
+
head,
|
|
531
|
+
issues,
|
|
532
|
+
summary,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
const INDEXNOW_STATUS = {
|
|
536
|
+
400: '요청 형식이 잘못됐다(400).',
|
|
537
|
+
403: '키가 유효하지 않다(403) — 키 파일 내용이 key 와 정확히 같은지, 파일이 공개로 열리는지 확인해.',
|
|
538
|
+
422: 'URL 이 host 나 키 파일 위치와 맞지 않는다(422) — URL 은 키 파일과 같은 스킴·호스트, 같은 디렉터리 이하여야 한다.',
|
|
539
|
+
429: '너무 자주 보냈다(429) — 스팸으로 볼 수 있으니 한참 뒤에 다시 보내. 자동 재시도는 하지 않았다.',
|
|
540
|
+
};
|
|
541
|
+
/**
|
|
542
|
+
* 네이버 IndexNow 로 URL 변경을 알린다.
|
|
543
|
+
*
|
|
544
|
+
* 보내기 전에 키 파일을 직접 받아 key 와 같은지 확인한다 — 틀린 키는 엔드포인트가 403 만 돌려주고,
|
|
545
|
+
* 같은 호스트에 틀린 키로 반복 제출하면 스팸 판정 위험만 커진다. 같은 URL 재제출은 안전하다.
|
|
546
|
+
*/
|
|
547
|
+
export async function submitIndexNow({ urls, key, keyLocation }) {
|
|
548
|
+
if (!INDEXNOW_KEY_PATTERN.test(key)) {
|
|
549
|
+
throw new Error('key 는 8–128자의 영문·숫자·대시(-)여야 합니다.');
|
|
550
|
+
}
|
|
551
|
+
// 정규화(호스트 소문자·# 제거) 뒤에 중복을 없앤다 — 입력 문자열 기준이면 같은 URL 이 여러 번 나간다.
|
|
552
|
+
const byHref = new Map();
|
|
553
|
+
for (const raw of urls) {
|
|
554
|
+
if (!raw.trim())
|
|
555
|
+
continue;
|
|
556
|
+
const url = parseHttpUrl(raw, 'urls');
|
|
557
|
+
byHref.set(url.href, url);
|
|
558
|
+
}
|
|
559
|
+
const parsed = [...byHref.values()];
|
|
560
|
+
if (parsed.length === 0)
|
|
561
|
+
throw new Error('urls 가 비었습니다.');
|
|
562
|
+
if (parsed.length > INDEXNOW_MAX_URLS)
|
|
563
|
+
throw new Error(`한 번에 최대 ${INDEXNOW_MAX_URLS}개까지 보낼 수 있습니다.`);
|
|
564
|
+
// IndexNow 의 범위는 URL 접두어다 — 호스트만이 아니라 스킴까지 같아야 한다.
|
|
565
|
+
const origin = parsed[0].origin;
|
|
566
|
+
const foreign = parsed.find((u) => u.origin !== origin);
|
|
567
|
+
if (foreign) {
|
|
568
|
+
throw new Error(`모든 URL 은 같은 스킴·호스트여야 합니다 — ${origin} 와 ${foreign.origin} 가 섞였습니다. 나눠서 보내세요.`);
|
|
569
|
+
}
|
|
570
|
+
const keyUrl = keyLocation
|
|
571
|
+
? parseHttpUrl(keyLocation, 'keyLocation')
|
|
572
|
+
: new URL(`/${encodePathSegment(key)}.txt`, origin);
|
|
573
|
+
if (keyUrl.origin !== origin) {
|
|
574
|
+
throw new Error(`keyLocation 은 제출 URL 과 같은 스킴·호스트(${origin})에 있어야 합니다.`);
|
|
575
|
+
}
|
|
576
|
+
const keyDir = keyUrl.pathname.slice(0, keyUrl.pathname.lastIndexOf('/') + 1);
|
|
577
|
+
const outside = parsed.find((u) => !u.pathname.startsWith(keyDir));
|
|
578
|
+
if (outside) {
|
|
579
|
+
throw new Error(`키 파일 디렉터리(${keyDir}) 밖의 URL 은 보낼 수 없습니다 — ${outside.href}`);
|
|
580
|
+
}
|
|
581
|
+
// 사전 확인: 키 파일이 리다이렉트 없이 200 으로, 내용이 key 그대로 열려야 한다.
|
|
582
|
+
const keyRes = await fetchWithTimeout(keyUrl, { redirect: 'manual' });
|
|
583
|
+
let keyBody = '';
|
|
584
|
+
if (keyRes.status === 200)
|
|
585
|
+
keyBody = (await readCapped(keyRes, 4096)).text.trim();
|
|
586
|
+
else
|
|
587
|
+
await keyRes.body?.cancel();
|
|
588
|
+
if (keyRes.status !== 200 || keyBody !== key) {
|
|
589
|
+
const why = keyRes.status !== 200 ? `응답 ${keyRes.status}` : '파일 내용이 key 와 다름';
|
|
590
|
+
throw new Error([
|
|
591
|
+
`키 파일 확인 실패: ${keyUrl.href} (${why}). 아무것도 제출하지 않았습니다.`,
|
|
592
|
+
`→ 사이트에 ${keyUrl.pathname} 파일을 올리고 내용으로 key 한 줄만 넣으세요 (UTF-8, 리다이렉트 없이 200).`,
|
|
593
|
+
' 정적 사이트는 보통 public/ (또는 static/) 폴더 루트에 두면 됩니다.',
|
|
594
|
+
].join('\n'));
|
|
595
|
+
}
|
|
596
|
+
// 429 도 재시도하지 않는다 — IndexNow 의 429 는 "스팸 의심"이라 연타가 상황을 악화시킨다.
|
|
597
|
+
const res = await fetchWithTimeout(NAVER_INDEXNOW_ENDPOINT, {
|
|
598
|
+
method: 'POST',
|
|
599
|
+
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
600
|
+
body: JSON.stringify({
|
|
601
|
+
host: keyUrl.host,
|
|
602
|
+
key,
|
|
603
|
+
...(keyLocation ? { keyLocation: keyUrl.href } : {}),
|
|
604
|
+
urlList: parsed.map((u) => u.href),
|
|
605
|
+
}),
|
|
606
|
+
}, { maxAttempts: 1 });
|
|
607
|
+
const detail = (await readCapped(res, 2048)).text.trim().slice(0, 300);
|
|
608
|
+
if (res.status === 200 || res.status === 202) {
|
|
609
|
+
return { endpoint: NAVER_INDEXNOW_ENDPOINT, status: res.status, host: keyUrl.host, keyLocation: keyUrl.href, submitted: parsed.length };
|
|
610
|
+
}
|
|
611
|
+
throw new Error(`네이버 IndexNow 제출 실패: ${INDEXNOW_STATUS[res.status] ?? `HTTP ${res.status}`}${detail ? `\n응답: ${detail}` : ''}`);
|
|
612
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import * as naver from '../naver/tools.js';
|
|
3
|
+
import { jsonResult, textResult } from '../lib/mcp-response.js';
|
|
4
|
+
export function registerNaverTools(server) {
|
|
5
|
+
server.tool('naver_check_page', '네이버 검색로봇(Yeti) 관점에서 URL 1개의 색인 가능성 점검 — 리다이렉트 체인(301/302), robots.txt 의 Yeti 허용 여부, 상태 코드, noindex(meta robots/yeti·X-Robots-Tag), canonical, title·description·Open Graph, JS 렌더링 의존(원본 HTML 본문 부족), naver-site-verification. issues 를 error/warn/info 로 반환. 서치어드바이저에는 공개 API 가 없어 콘솔의 수집 현황 대신 이걸로 진단한다. 인증 불필요.', {
|
|
6
|
+
url: z.string().describe('점검할 전체 URL (예: https://example.com/post/1)'),
|
|
7
|
+
}, async ({ url }) => jsonResult(await naver.checkPage(url)));
|
|
8
|
+
server.tool('naver_indexnow_submit', "네이버 IndexNow 로 새로 만들거나 수정·삭제한 URL 을 즉시 알려 수집을 앞당긴다 (IndexNow 참여 엔진끼리 공유됨). 사전 조건: 사이트 루트에 '<key>.txt' 파일(내용 = key 한 줄)을 공개로 올려 둘 것 — 제출 전에 이 파일을 직접 확인하고, 틀리면 아무것도 보내지 않는다. URL 은 모두 같은 호스트, 1회 최대 10,000개. 같은 URL 재제출은 안전. 사이트맵·RSS 제출은 API 가 없어 서치어드바이저 콘솔에서 해야 한다.", {
|
|
9
|
+
urls: z.array(z.string()).min(1).max(naver.INDEXNOW_MAX_URLS).describe('알릴 전체 URL 목록 (같은 호스트)'),
|
|
10
|
+
key: z.string().regex(naver.INDEXNOW_KEY_PATTERN).describe('IndexNow 키 — 8–128자 영문·숫자·대시. 없으면 32자 16진수 등으로 새로 만들어 키 파일부터 배포'),
|
|
11
|
+
keyLocation: z.string().optional().describe('키 파일이 루트(/<key>.txt)가 아닐 때의 전체 URL. 이 경우 URL 은 그 디렉터리 이하만 가능'),
|
|
12
|
+
}, async ({ urls, key, keyLocation }) => {
|
|
13
|
+
const r = await naver.submitIndexNow({ urls, key, keyLocation });
|
|
14
|
+
return textResult([
|
|
15
|
+
`✅ 네이버 IndexNow 제출 완료 (HTTP ${r.status}${r.status === 202 ? ' — 접수됨, 키 검증 대기' : ''}).`,
|
|
16
|
+
` 호스트: ${r.host} · URL ${r.submitted}개 · 키 파일: ${r.keyLocation}`,
|
|
17
|
+
'',
|
|
18
|
+
'수집 여부는 서치어드바이저 콘솔(요청 → 웹 페이지 수집 / 리포트)에서 확인해. 제출은 수집을 앞당길 뿐 색인을 보장하지 않아 — naver_check_page 로 막는 요소가 없는지 먼저 봐.',
|
|
19
|
+
]);
|
|
20
|
+
});
|
|
21
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -15,6 +15,7 @@ import { registerThreadsTools } from './registers/threads.js';
|
|
|
15
15
|
import { registerFacebookTools } from './registers/facebook.js';
|
|
16
16
|
import { registerGoogleAdsTools } from './registers/googleads.js';
|
|
17
17
|
import { registerGscTools } from './registers/gsc.js';
|
|
18
|
+
import { registerNaverTools } from './registers/naver.js';
|
|
18
19
|
import { registerGa4Tools } from './registers/ga4.js';
|
|
19
20
|
import { registerJenkinsTools } from './registers/jenkins.js';
|
|
20
21
|
import { registerAndroidTools } from './registers/android.js';
|
|
@@ -63,6 +64,7 @@ export function buildServer(version, options = {}) {
|
|
|
63
64
|
registerFacebookTools(registrar);
|
|
64
65
|
registerGoogleAdsTools(registrar);
|
|
65
66
|
registerGscTools(registrar);
|
|
67
|
+
registerNaverTools(registrar);
|
|
66
68
|
registerGa4Tools(registrar);
|
|
67
69
|
registerJenkinsTools(registrar);
|
|
68
70
|
registerAndroidTools(registrar);
|
package/package.json
CHANGED
package/tool-manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "등록된 MCP 도구 + 도메인 메타데이터(label·credential·summary) + 도구 분류(write·destructive·local·idempotent → MCP annotations·confirm 가드) + toolset 그룹·폐기 별칭의 SSOT (src/lib/tool-registrar.ts 가 읽는다). src/__tests__/tool-manifest.test.ts 가 실제 서버 등록 목록과 diff 하고 메타데이터 정합성을 검사한다. 도구 추가/삭제/개명 시 이 파일을 함께 갱신할 것 — 산문 문서에는 정확한 개수를 쓰지 말고 이 파일을 가리킬 것. mimi-seed://tools/catalog 리소스가 이 파일을 그대로 서빙한다.",
|
|
3
|
-
"total":
|
|
3
|
+
"total": 247,
|
|
4
4
|
"alwaysOn": [
|
|
5
5
|
"auth",
|
|
6
6
|
"checks"
|
|
@@ -507,6 +507,24 @@
|
|
|
507
507
|
"gsc_submit_sitemap"
|
|
508
508
|
]
|
|
509
509
|
},
|
|
510
|
+
"naver": {
|
|
511
|
+
"label": "Naver Search Advisor",
|
|
512
|
+
"credential": "없음 (IndexNow 키 파일)",
|
|
513
|
+
"summary": "Yeti 크롤러 관점 색인 점검·IndexNow URL 제출",
|
|
514
|
+
"tools": [
|
|
515
|
+
"naver_check_page",
|
|
516
|
+
"naver_indexnow_submit"
|
|
517
|
+
],
|
|
518
|
+
"write": [
|
|
519
|
+
"naver_indexnow_submit"
|
|
520
|
+
],
|
|
521
|
+
"destructive": [],
|
|
522
|
+
"ownGate": [],
|
|
523
|
+
"local": [],
|
|
524
|
+
"idempotent": [
|
|
525
|
+
"naver_indexnow_submit"
|
|
526
|
+
]
|
|
527
|
+
},
|
|
510
528
|
"iam": {
|
|
511
529
|
"label": "Google Cloud IAM",
|
|
512
530
|
"credential": "Google OAuth",
|