@yoonion/mimi-seed-mcp 0.21.0 → 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 +3 -2
- package/assets/agent-guide.md +23 -8
- package/dist/appstore/apps.js +4 -1
- package/dist/jenkins/builds.d.ts +93 -0
- package/dist/jenkins/builds.js +324 -0
- package/dist/jenkins/http.d.ts +29 -0
- package/dist/jenkins/http.js +47 -13
- package/dist/jenkins/messages.js +1 -0
- package/dist/lib/tool-registrar.d.ts +1 -0
- package/dist/lib/tool-registrar.js +16 -3
- 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/playstore/reviews.d.ts +46 -3
- package/dist/playstore/reviews.js +84 -9
- package/dist/prompts.js +1 -1
- package/dist/registers/jenkins.js +31 -1
- package/dist/registers/naver.d.ts +2 -0
- package/dist/registers/naver.js +21 -0
- package/dist/registers/playstore.js +7 -3
- package/dist/server.js +2 -0
- package/dist/video/schemas.d.ts +10 -10
- package/package.json +1 -1
- package/tool-manifest.json +26 -4
package/dist/jenkins/http.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fetchWithTimeout } from '../lib/http.js';
|
|
2
|
+
import { encodePathSegment } from '../lib/url-path.js';
|
|
2
3
|
export function basicAuth(username, token) {
|
|
3
4
|
return 'Basic ' + Buffer.from(`${username}:${token}`).toString('base64');
|
|
4
5
|
}
|
|
@@ -13,32 +14,67 @@ export function authHeaders(cfg) {
|
|
|
13
14
|
* 구버전 Jenkins / 비밀번호 인증 환경에서 POST 403 방지.
|
|
14
15
|
*/
|
|
15
16
|
export async function getCrumb(cfg) {
|
|
17
|
+
const result = await requestCrumb(cfg);
|
|
18
|
+
return result.kind === 'ok' ? result.headers : {};
|
|
19
|
+
}
|
|
20
|
+
/** 헤더 이름·값으로 안전한가 — 서버가 준 문자열을 그대로 헤더에 넣으면 fetch 가 요청 도중 던질 수 있다. */
|
|
21
|
+
const CRUMB_FIELD = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,128}$/;
|
|
22
|
+
const CRUMB_VALUE = /^[\x21-\x7e]{1,512}$/;
|
|
23
|
+
/**
|
|
24
|
+
* crumb 을 결과 종류와 함께 돌려준다. getCrumb 은 이것의 best-effort 판(실패 = 빈 객체)이다.
|
|
25
|
+
* jenkins_trigger_build 는 `redirect: 'manual'` 로 불러 인증 헤더를 단 채 다른 곳으로 따라가지 않고,
|
|
26
|
+
* `failed` 면 POST 하지 않는다.
|
|
27
|
+
*/
|
|
28
|
+
export async function requestCrumb(cfg, options = {}) {
|
|
29
|
+
let res;
|
|
30
|
+
try {
|
|
31
|
+
res = await fetchWithTimeout(`${baseUrl(cfg)}/crumbIssuer/api/json`, { headers: authHeaders(cfg), ...(options.redirect && { redirect: options.redirect }) }, options.maxAttempts ? { maxAttempts: options.maxAttempts } : {});
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { kind: 'failed' };
|
|
35
|
+
}
|
|
36
|
+
if (res.status === 404)
|
|
37
|
+
return { kind: 'disabled' };
|
|
38
|
+
if (!res.ok)
|
|
39
|
+
return { kind: 'failed', status: res.status };
|
|
16
40
|
try {
|
|
17
|
-
const res = await fetchWithTimeout(`${baseUrl(cfg)}/crumbIssuer/api/json`, {
|
|
18
|
-
headers: authHeaders(cfg),
|
|
19
|
-
});
|
|
20
|
-
if (!res.ok)
|
|
21
|
-
return {};
|
|
22
41
|
const data = (await res.json());
|
|
23
|
-
if (data.crumbRequestField && data.
|
|
24
|
-
|
|
42
|
+
if (typeof data.crumbRequestField === 'string' && CRUMB_FIELD.test(data.crumbRequestField)
|
|
43
|
+
&& typeof data.crumb === 'string' && CRUMB_VALUE.test(data.crumb)) {
|
|
44
|
+
return { kind: 'ok', headers: { [data.crumbRequestField]: data.crumb } };
|
|
25
45
|
}
|
|
26
|
-
return {};
|
|
27
46
|
}
|
|
28
47
|
catch {
|
|
29
|
-
|
|
48
|
+
// JSON 아님
|
|
30
49
|
}
|
|
50
|
+
return { kind: 'failed', status: res.status };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 엄격한 잡 경로 검사 — 빌드 도구(jenkins_trigger_build · jenkins_get_build_status)의 입력 스키마가 쓴다.
|
|
54
|
+
*
|
|
55
|
+
* jobUrl 은 세그먼트마다 encodePathSegment 로 인코딩하고 `.`/`..` 를 거부하지만, 옛 도구 호환 때문에
|
|
56
|
+
* 빈 세그먼트(`a//b`, 끝의 `/`)는 조용히 버린다. 빌드를 **실행**하는 경로에서는 그런 모호함도 받지 않는다:
|
|
57
|
+
* 빈·공백뿐인 세그먼트, `.`/`..`, 백슬래시(일부 프록시가 `/` 로 바꾼다), 제어 문자를 모두 거절한다.
|
|
58
|
+
*/
|
|
59
|
+
export function isStrictJobPath(jobPath) {
|
|
60
|
+
return jobPath.split('/').every((segment) => segment.trim().length > 0
|
|
61
|
+
&& segment !== '.'
|
|
62
|
+
&& segment !== '..'
|
|
63
|
+
&& !segment.includes('\\')
|
|
64
|
+
&& ![...segment].some((ch) => ch.charCodeAt(0) < 0x20 || ch.charCodeAt(0) === 0x7f));
|
|
31
65
|
}
|
|
32
66
|
/**
|
|
33
67
|
* 잡 경로를 Jenkins URL 로 바꾼다. 폴더는 `/` 로 구분한다.
|
|
34
68
|
* "my-app" -> <base>/job/my-app
|
|
35
69
|
* "team-folder/my-app" -> <base>/job/team-folder/job/my-app
|
|
70
|
+
* 세그먼트는 encodePathSegment 로 인코딩한다 — `..` 세그먼트가 URL 정규화로 상위 경로(다른 잡)를
|
|
71
|
+
* 가리키지 못하게 거부한다.
|
|
36
72
|
*/
|
|
37
73
|
export function jobUrl(cfg, jobPath) {
|
|
38
74
|
const segments = jobPath.split('/').filter(Boolean);
|
|
39
75
|
if (segments.length === 0)
|
|
40
76
|
throw new Error('잡 이름이 비어 있습니다.');
|
|
41
|
-
const suffix = segments.map((s) => `job/${
|
|
77
|
+
const suffix = segments.map((s) => `job/${encodePathSegment(s)}`).join('/');
|
|
42
78
|
return `${baseUrl(cfg)}/${suffix}`;
|
|
43
79
|
}
|
|
44
80
|
/**
|
|
@@ -50,9 +86,7 @@ export function createItemUrl(cfg, jobPath) {
|
|
|
50
86
|
if (segments.length === 0)
|
|
51
87
|
throw new Error('잡 이름이 비어 있습니다.');
|
|
52
88
|
const leaf = segments.pop();
|
|
53
|
-
const parent = segments.length
|
|
54
|
-
? `${baseUrl(cfg)}/${segments.map((s) => `job/${encodeURIComponent(s)}`).join('/')}`
|
|
55
|
-
: baseUrl(cfg);
|
|
89
|
+
const parent = segments.length ? jobUrl(cfg, segments.join('/')) : baseUrl(cfg);
|
|
56
90
|
return `${parent}/createItem?name=${encodeURIComponent(leaf)}`;
|
|
57
91
|
}
|
|
58
92
|
/** 실패 응답의 본문 일부를 붙여 에러를 만든다. Jenkins 는 원인을 본문에만 담는 경우가 많다. */
|
package/dist/jenkins/messages.js
CHANGED
|
@@ -101,5 +101,6 @@ export function jenkinsConfiguredLines({ url, username }) {
|
|
|
101
101
|
'jenkins_list_credentials 로 등록된 credential 목록을 확인하거나,',
|
|
102
102
|
'jenkins_create_credential / jenkins_upload_keystore 로 credential을 추가하세요.',
|
|
103
103
|
'잡은 jenkins_list_jobs / jenkins_get_job_config / jenkins_create_job / jenkins_update_job 로 다룹니다.',
|
|
104
|
+
'빌드는 jenkins_trigger_build → jenkins_get_queue_item → jenkins_get_build_status 순서로 실행·추적합니다.',
|
|
104
105
|
];
|
|
105
106
|
}
|
|
@@ -50,6 +50,7 @@ export declare function toolTitle(name: string): string;
|
|
|
50
50
|
export declare function annotationsFor(meta: ToolMeta): ToolAnnotations & {
|
|
51
51
|
title: string;
|
|
52
52
|
};
|
|
53
|
+
export declare function previewValue(key: string, value: unknown): string;
|
|
53
54
|
export declare function confirmPreview(name: string, args: Record<string, unknown>): CallToolResult;
|
|
54
55
|
export interface ToolRegistrar {
|
|
55
56
|
/** `McpServer.tool(name, description, shape, handler)` 와 같은 모양 — register 파일 호출부는 그대로다. */
|
|
@@ -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`. */
|
|
@@ -79,10 +80,22 @@ export function annotationsFor(meta) {
|
|
|
79
80
|
}
|
|
80
81
|
// ── confirm guard ──────────────────────────────────────────────────────────
|
|
81
82
|
const SECRET_KEY = /secret|token|password|passphrase|private|base64|credential_json|serviceAccountJson/i;
|
|
82
|
-
|
|
83
|
+
/**
|
|
84
|
+
* 중첩된 객체·배열 안의 비밀처럼 보이는 키도 가린다 — 예: jenkins_trigger_build 의
|
|
85
|
+
* `parameters: { DEPLOY_TOKEN: … }`. 최상위 키만 보던 시절엔 값이 preview 에 그대로 찍혔다.
|
|
86
|
+
*/
|
|
87
|
+
function redactNested(value) {
|
|
88
|
+
if (Array.isArray(value))
|
|
89
|
+
return value.map(redactNested);
|
|
90
|
+
if (value !== null && typeof value === 'object') {
|
|
91
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, SECRET_KEY.test(k) ? '(redacted)' : redactNested(v)]));
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
export function previewValue(key, value) {
|
|
83
96
|
if (SECRET_KEY.test(key))
|
|
84
97
|
return '(redacted)';
|
|
85
|
-
const raw = typeof value === 'string' ? value : JSON.stringify(value);
|
|
98
|
+
const raw = typeof value === 'string' ? value : JSON.stringify(redactNested(value));
|
|
86
99
|
if (raw === undefined)
|
|
87
100
|
return 'undefined';
|
|
88
101
|
return raw.length > 200 ? `${raw.slice(0, 200)}… (${raw.length}자)` : raw;
|
|
@@ -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>;
|