@milcho0604/velog-mcp 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +41 -3
- package/README.md +37 -3
- package/dist/auth.js +30 -2
- package/dist/auth.js.map +1 -1
- package/dist/client.d.ts +54 -2
- package/dist/client.js +145 -11
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/me.d.ts +2 -2
- package/dist/me.js +7 -4
- package/dist/me.js.map +1 -1
- package/dist/render/index.js +21 -11
- package/dist/render/index.js.map +1 -1
- package/dist/serial.d.ts +60 -0
- package/dist/serial.js +68 -0
- package/dist/serial.js.map +1 -0
- package/dist/series.d.ts +47 -0
- package/dist/series.js +109 -0
- package/dist/series.js.map +1 -0
- package/dist/thumbnail.d.ts +63 -0
- package/dist/thumbnail.js +139 -0
- package/dist/thumbnail.js.map +1 -0
- package/dist/tools/drafts.js +72 -25
- package/dist/tools/drafts.js.map +1 -1
- package/dist/tools/export.js +66 -5
- package/dist/tools/export.js.map +1 -1
- package/dist/tools/images.js +49 -13
- package/dist/tools/images.js.map +1 -1
- package/dist/tools/profile-edit.js +26 -17
- package/dist/tools/profile-edit.js.map +1 -1
- package/dist/tools/publish.js +106 -33
- package/dist/tools/publish.js.map +1 -1
- package/dist/tools/stats.d.ts +3 -1
- package/dist/tools/stats.js +8 -5
- package/dist/tools/stats.js.map +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 썸네일 자동 채움 — 본문에 이미 있는 이미지를 목록 카드에 쓴다.
|
|
3
|
+
*
|
|
4
|
+
* ★ 왜 있는가 — 썸네일이 없으면 글 목록·공유 카드가 글자만 나온다. 그런데
|
|
5
|
+
* 본문에 그림을 넣은 사람은 이미 쓸 만한 이미지를 가지고 있다. 한 번 더
|
|
6
|
+
* 지정하게 만들 이유가 없다.
|
|
7
|
+
*
|
|
8
|
+
* ★★ 이 모듈은 **본문을 고치지 않는다.** 읽기만 한다. 반환값은 '무엇을 쓸지'와
|
|
9
|
+
* '왜 그렇게 정했는지'뿐이고, 실제 적용은 호출부가 한다.
|
|
10
|
+
*/
|
|
11
|
+
import { isSafeImageUrl } from "./slug.js";
|
|
12
|
+
/** 문자 개수. 괄호 균형 검사에만 쓴다. */
|
|
13
|
+
function countOf(text, ch) {
|
|
14
|
+
let n = 0;
|
|
15
|
+
for (const c of text)
|
|
16
|
+
if (c === ch)
|
|
17
|
+
n++;
|
|
18
|
+
return n;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* 마크다운 본문에서 이미지 URL 을 등장 순서대로 뽑는다.
|
|
22
|
+
*
|
|
23
|
+
* 다루는 형태:
|
|
24
|
+
* - `` · ``
|
|
25
|
+
* - `<img src="url">` (HTML 을 섞어 쓰는 사람이 많다)
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ 코드블록 안의 이미지는 **제외한다.** 예제로 적어둔 마크다운이 썸네일이
|
|
28
|
+
* 되어버리면 황당하다. 펜스(``` 또는 ~~~)와 인라인 코드(`...`)를 먼저 지운다.
|
|
29
|
+
*/
|
|
30
|
+
export function extractImageUrls(body) {
|
|
31
|
+
// ★ 지우는 순서가 중요하다. 펜스를 먼저 지워야 그 안의 백틱이 인라인 코드로
|
|
32
|
+
// 잘못 짝지어지지 않는다.
|
|
33
|
+
const withoutCode = body
|
|
34
|
+
.replace(/^[ \t]*(`{3,}|~{3,})[\s\S]*?^[ \t]*\1[ \t]*$/gm, '')
|
|
35
|
+
.replace(/`[^`\n]*`/g, '');
|
|
36
|
+
const found = [];
|
|
37
|
+
const push = (raw) => {
|
|
38
|
+
if (!raw)
|
|
39
|
+
return;
|
|
40
|
+
// 마크다운은 URL 뒤에 제목을 붙일 수 있다: (url "제목")
|
|
41
|
+
const url = raw.trim().split(/\s+/)[0]?.replace(/^<|>$/g, '');
|
|
42
|
+
if (!url || !isSafeImageUrl(url))
|
|
43
|
+
return;
|
|
44
|
+
// ★★ 괄호가 안 맞으면 **버린다.** `.png)` 는 `)` 에서 끊겨
|
|
45
|
+
// `https://x/y(1` 이 잡히는데, 이것도 https 라 URL 검사를 통과한다.
|
|
46
|
+
// 그대로 두면 **깨진 주소가 조용히 썸네일이 된다** — 아무도 모르게 목록
|
|
47
|
+
// 카드만 깨진다. 애매하면 넣지 않는 쪽이 맞다.
|
|
48
|
+
if (countOf(url, '(') !== countOf(url, ')'))
|
|
49
|
+
return;
|
|
50
|
+
if (!found.includes(url))
|
|
51
|
+
found.push(url);
|
|
52
|
+
};
|
|
53
|
+
// alt 안의 대괄호 한 겹까지 허용한다: `![도표[1]](url)`
|
|
54
|
+
for (const m of withoutCode.matchAll(/!\[(?:[^[\]]|\[[^\]]*\])*\]\(([^)]+)\)/g))
|
|
55
|
+
push(m[1]);
|
|
56
|
+
for (const m of withoutCode.matchAll(/<img\b[^>]*?\ssrc\s*=\s*["']([^"']+)["']/gi)) {
|
|
57
|
+
push(m[1]);
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 무엇을 썸네일로 쓸지 정한다.
|
|
63
|
+
*
|
|
64
|
+
* 우선순위:
|
|
65
|
+
* 1. 호출자가 준 값 → 그대로 쓴다 (`explicit`)
|
|
66
|
+
* 2. 호출자가 **끄겠다고** 명시 → 안 쓴다 (`opted-out`)
|
|
67
|
+
* 3. 본문 첫 이미지 → 자동 (`auto`)
|
|
68
|
+
* 4. 본문에 이미지가 없음 → 안 쓴다 (`none`)
|
|
69
|
+
*
|
|
70
|
+
* ★ 끄는 방법을 둔 이유 — 이 도구는 나만 쓰는 게 아니다. 썸네일을 **일부러**
|
|
71
|
+
* 비워 두는 사람이 있고, 자동 채움이 그 의도를 조용히 덮으면 안 된다.
|
|
72
|
+
* 빈 문자열이나 null 을 주면 "비워 둬라"로 읽는다.
|
|
73
|
+
*/
|
|
74
|
+
export function chooseThumbnail(requested, body) {
|
|
75
|
+
const candidates = extractImageUrls(body);
|
|
76
|
+
if (typeof requested === 'string' && requested.trim() !== '') {
|
|
77
|
+
return { url: requested, candidates, reason: 'explicit' };
|
|
78
|
+
}
|
|
79
|
+
// ★ null 과 빈 문자열은 "자동으로 넣지 마라"는 뜻이다. undefined(미지정)와
|
|
80
|
+
// 구분해야 한다 — 미지정은 "알아서 해달라"이고 이건 명시적 거부다.
|
|
81
|
+
if (requested !== undefined) {
|
|
82
|
+
return { url: undefined, candidates, reason: 'opted-out' };
|
|
83
|
+
}
|
|
84
|
+
const first = candidates[0];
|
|
85
|
+
if (first === undefined)
|
|
86
|
+
return { url: undefined, candidates, reason: 'none' };
|
|
87
|
+
return { url: first, candidates, reason: 'auto' };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 병합 수정(velog_update_post)용. 생성과 규칙이 다르다.
|
|
91
|
+
*
|
|
92
|
+
* ★★ **기존 썸네일이 최우선이다.** 이미 붙어 있는 그림을 본문 첫 이미지로
|
|
93
|
+
* 갈아치우면, 제목만 고치려던 사람이 목록 카드가 바뀌는 걸 당한다.
|
|
94
|
+
* 자동 채움은 **비어 있을 때만** 한다.
|
|
95
|
+
*
|
|
96
|
+
* ⚠️ `null` 을 줘도 **기존 썸네일을 지우지 않는다.** 여기서 null 은 "자동으로
|
|
97
|
+
* 채우지 마라"이지 "지워라"가 아니다. 지우는 건 되돌리기 어려운데 그 의도를
|
|
98
|
+
* null 하나로 단정할 수 없다 — 지우려면 벨로그에서 직접 하는 게 맞다.
|
|
99
|
+
*/
|
|
100
|
+
export function chooseThumbnailForUpdate(requested, existing, body) {
|
|
101
|
+
if (typeof requested === 'string' && requested.trim() !== '') {
|
|
102
|
+
return { url: requested, candidates: extractImageUrls(body), reason: 'explicit' };
|
|
103
|
+
}
|
|
104
|
+
// ★★ 생략하면 **기존 값을 그대로 잇는다. 비어 있어도 마찬가지다.**
|
|
105
|
+
// 예전엔 기존이 비었을 때 본문 첫 이미지로 채웠는데, 이 도구는 문서에
|
|
106
|
+
// "생략한 필드는 유지된다"고 약속해 뒀다. 제목만 고치려던 호출이 **일부러
|
|
107
|
+
// 비워 둔 썸네일을 채워버리는** 건 그 약속을 깨는 것이다.
|
|
108
|
+
// 자동 채움은 새로 쓰는 경로(create/publish)에만 둔다.
|
|
109
|
+
// (코덱스 교차검증에서 잡혔다.)
|
|
110
|
+
return { url: existing ?? undefined, candidates: [], reason: 'explicit' };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* 결과 메시지에 붙일 안내. 붙일 말이 없으면 빈 문자열.
|
|
114
|
+
*
|
|
115
|
+
* ★ 조용히 하지 않는다. 자동으로 넣었으면 **넣었다고 말한다.** 내가 시키지 않은
|
|
116
|
+
* 변경이 결과에 안 보이면 그게 사고다.
|
|
117
|
+
*/
|
|
118
|
+
export function describeThumbnail(choice) {
|
|
119
|
+
if (choice.reason === 'auto') {
|
|
120
|
+
const others = choice.candidates.length - 1;
|
|
121
|
+
const head = `\n\n🖼️ 썸네일을 본문 첫 이미지로 **자동 설정**했습니다.\n ${choice.url}`;
|
|
122
|
+
if (others <= 0)
|
|
123
|
+
return head;
|
|
124
|
+
const list = choice.candidates
|
|
125
|
+
.slice(1, 6)
|
|
126
|
+
.map((u, i) => ` ${i + 2}. ${u}`)
|
|
127
|
+
.join('\n');
|
|
128
|
+
const more = others > 5 ? `\n … 외 ${others - 5}개` : '';
|
|
129
|
+
return (`${head}\n\n 본문에 이미지가 ${choice.candidates.length}개 있습니다. ` +
|
|
130
|
+
`다른 걸 쓰려면 thumbnail 에 아래 중 하나를 주세요.\n${list}${more}`);
|
|
131
|
+
}
|
|
132
|
+
if (choice.reason === 'none') {
|
|
133
|
+
return ('\n\n💡 썸네일이 없습니다. 본문에 이미지가 없어 자동으로 채우지 못했습니다 — ' +
|
|
134
|
+
'목록·공유 카드에 그림이 필요하면 thumbnail 에 이미지 URL 을 주거나 ' +
|
|
135
|
+
'velog_render_diagram / velog_upload_image 로 하나 만들어 붙이세요.');
|
|
136
|
+
}
|
|
137
|
+
return '';
|
|
138
|
+
}
|
|
139
|
+
//# sourceMappingURL=thumbnail.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"thumbnail.js","sourceRoot":"","sources":["../src/thumbnail.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAE3C,4BAA4B;AAC5B,SAAS,OAAO,CAAC,IAAY,EAAE,EAAU;IACxC,IAAI,CAAC,GAAG,CAAC,CAAC;IACV,KAAK,MAAM,CAAC,IAAI,IAAI;QAAE,IAAI,CAAC,KAAK,EAAE;YAAE,CAAC,EAAE,CAAC;IACxC,OAAO,CAAC,CAAC;AACV,CAAC;AAYD;;;;;;;;;GASG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAY;IAC5C,8CAA8C;IAC9C,kBAAkB;IAClB,MAAM,WAAW,GAAG,IAAI;SACtB,OAAO,CAAC,gDAAgD,EAAE,EAAE,CAAC;SAC7D,OAAO,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC;IAE5B,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,MAAM,IAAI,GAAG,CAAC,GAAuB,EAAQ,EAAE;QAC9C,IAAI,CAAC,GAAG;YAAE,OAAO;QACjB,uCAAuC;QACvC,MAAM,GAAG,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;QAC9D,IAAI,CAAC,GAAG,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC;YAAE,OAAO;QACzC,+DAA+D;QAC/D,sDAAsD;QACtD,+CAA+C;QAC/C,+BAA+B;QAC/B,IAAI,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC,KAAK,OAAO,CAAC,GAAG,EAAE,GAAG,CAAC;YAAE,OAAO;QACpD,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC;YAAE,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC3C,CAAC,CAAC;IAEF,yCAAyC;IACzC,KAAK,MAAM,CAAC,IAAI,WAAW,CAAC,QAAQ,CAAC,yCAAyC,CAAC;QAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC5F,KAAK,MAAM,CAAC,IAAI,WAAW,CAAC,QAAQ,CAAC,4CAA4C,CAAC,EAAE,CAAC;QACpF,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACZ,CAAC;IACD,OAAO,KAAK,CAAC;AACd,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,eAAe,CAC9B,SAAoC,EACpC,IAAY;IAEZ,MAAM,UAAU,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAC;IAE1C,IAAI,OAAO,SAAS,KAAK,QAAQ,IAAI,SAAS,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAC9D,OAAO,EAAE,GAAG,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;IAC3D,CAAC;IACD,qDAAqD;IACrD,2CAA2C;IAC3C,IAAI,SAAS,KAAK,SAAS,EAAE,CAAC;QAC7B,OAAO,EAAE,GAAG,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC;IAC5D,CAAC;IACD,MAAM,KAAK,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC;IAC5B,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,EAAE,GAAG,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;IAC/E,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;AACnD,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,wBAAwB,CACvC,SAAoC,EACpC,QAAmC,EACnC,IAAY;IAEZ,IAAI,OAAO,SAAS,KAAK,QAAQ,IAAI,SAAS,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAC9D,OAAO,EAAE,GAAG,EAAE,SAAS,EAAE,UAAU,EAAE,gBAAgB,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;IACnF,CAAC;IACD,2CAA2C;IAC3C,4CAA4C;IAC5C,+CAA+C;IAC/C,sCAAsC;IACtC,0CAA0C;IAC1C,sBAAsB;IACtB,OAAO,EAAE,GAAG,EAAE,QAAQ,IAAI,SAAS,EAAE,UAAU,EAAE,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;AAC3E,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,iBAAiB,CAAC,MAAuB;IACxD,IAAI,MAAM,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;QAC9B,MAAM,MAAM,GAAG,MAAM,CAAC,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC;QAC5C,MAAM,IAAI,GACT,6CAA6C,MAAM,CAAC,GAAG,EAAE,CAAC;QAC3D,IAAI,MAAM,IAAI,CAAC;YAAE,OAAO,IAAI,CAAC;QAC7B,MAAM,IAAI,GAAG,MAAM,CAAC,UAAU;aAC5B,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC;aACX,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;aAClC,IAAI,CAAC,IAAI,CAAC,CAAC;QACb,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,YAAY,MAAM,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;QACzD,OAAO,CACN,GAAG,IAAI,mBAAmB,MAAM,CAAC,UAAU,CAAC,MAAM,UAAU;YAC5D,uCAAuC,IAAI,GAAG,IAAI,EAAE,CACpD,CAAC;IACH,CAAC;IACD,IAAI,MAAM,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;QAC9B,OAAO,CACN,iDAAiD;YACjD,+CAA+C;YAC/C,0DAA0D,CAC1D,CAAC;IACH,CAAC;IACD,OAAO,EAAE,CAAC;AACX,CAAC"}
|
package/dist/tools/drafts.js
CHANGED
|
@@ -15,6 +15,9 @@ import { toUrlSlug, isSafeImageUrl } from "../slug.js";
|
|
|
15
15
|
import { resolveMyUsername } from "../me.js";
|
|
16
16
|
import { assertOwned, assertOwnsSeries } from "../ownership.js";
|
|
17
17
|
import { READ_ONLY } from "./posts.js";
|
|
18
|
+
import { serializeWrite } from "../serial.js";
|
|
19
|
+
import { chooseThumbnail, describeThumbnail } from "../thumbnail.js";
|
|
20
|
+
import { seriesHintSafely, resolveSeriesId } from "../series.js";
|
|
18
21
|
/**
|
|
19
22
|
* ★ 발행 차단 지점. 이 값은 파라미터가 아니라 상수다.
|
|
20
23
|
* true = 임시저장(남에게 보이지 않음), false = 발행(되돌릴 수 없음).
|
|
@@ -36,6 +39,22 @@ const QUERY_POST_STATE = `
|
|
|
36
39
|
post(input: $input) { id title is_temp meta user { username } }
|
|
37
40
|
}
|
|
38
41
|
`;
|
|
42
|
+
/**
|
|
43
|
+
* 썸네일 입력 — 세 상태를 구분해야 한다.
|
|
44
|
+
*
|
|
45
|
+
* 미지정(undefined) → 본문 첫 이미지로 **자동** 채운다
|
|
46
|
+
* URL → 그대로 쓴다
|
|
47
|
+
* `null` / `""` → **자동 채움을 끈다** (일부러 비워 두는 사람의 의도를 지킨다)
|
|
48
|
+
*
|
|
49
|
+
* ★ nullable 을 뺐다가는 "끄는 방법"이 사라진다. 그러면 자동 채움이 강제가 된다.
|
|
50
|
+
*/
|
|
51
|
+
const THUMBNAIL_FIELD = z
|
|
52
|
+
.string()
|
|
53
|
+
.refine((v) => v === '' || isSafeImageUrl(v), 'http(s) 이미지 URL 만 허용합니다')
|
|
54
|
+
.nullable()
|
|
55
|
+
.optional()
|
|
56
|
+
.describe('썸네일 이미지 URL (http/https). 생략하면 본문 첫 이미지로 자동 설정한다. ' +
|
|
57
|
+
'자동 설정을 원하지 않으면 null 을 준다');
|
|
39
58
|
/** 벨로그가 정말 임시저장으로 받았는지 응답으로 확인한다. */
|
|
40
59
|
function assertStayedDraft(post) {
|
|
41
60
|
if (post.is_temp !== true) {
|
|
@@ -70,21 +89,31 @@ export function registerDraftTools(server, client) {
|
|
|
70
89
|
body: z.string().min(1).describe('본문 (마크다운)'),
|
|
71
90
|
tags: z.array(z.string()).default([]).describe('태그 목록'),
|
|
72
91
|
url_slug: z.string().optional().describe('생략하면 제목에서 생성'),
|
|
73
|
-
thumbnail:
|
|
74
|
-
.string()
|
|
75
|
-
.refine(isSafeImageUrl, 'http(s) 이미지 URL 만 허용합니다')
|
|
76
|
-
.optional()
|
|
77
|
-
.describe('썸네일 이미지 URL (http/https 만)'),
|
|
92
|
+
thumbnail: THUMBNAIL_FIELD,
|
|
78
93
|
series_id: z
|
|
79
94
|
.string()
|
|
80
95
|
.optional()
|
|
81
96
|
.describe('소속시킬 시리즈 id. ★ 벨로그는 임시저장 단계에서 이걸 무시한다 — ' +
|
|
82
|
-
'초안 생성 후 velog_update_draft 로 다시 지정해야 실제로
|
|
97
|
+
'초안 생성 후 velog_update_draft 로 다시 지정해야 실제로 붙는다. ' +
|
|
98
|
+
'생략하면 결과에 내 시리즈 목록을 함께 돌려준다'),
|
|
99
|
+
series_name: z
|
|
100
|
+
.string()
|
|
101
|
+
.min(1)
|
|
102
|
+
.optional()
|
|
103
|
+
.describe('시리즈 **이름**(id 대신). 저장 전에 내 시리즈에서 찾는다. ' +
|
|
104
|
+
'못 찾으면 저장하지 않고 목록을 알려준다'),
|
|
83
105
|
},
|
|
84
106
|
// 되돌릴 수 있는 쓰기다 — 비공개 초안이므로 파괴적이지 않다.
|
|
85
107
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
86
|
-
}, async ({ title, body, tags, url_slug, thumbnail, series_id }) =>
|
|
108
|
+
}, async ({ title, body, tags, url_slug, thumbnail, series_id: rawSeriesId, series_name }, extra) =>
|
|
109
|
+
// ★ 같은 대상에 대한 쓰기는 줄을 세운다 — 이유는 src/serial.ts
|
|
110
|
+
serializeWrite('post:new', async () => {
|
|
87
111
|
client.requireAuth('velog_create_draft');
|
|
112
|
+
let series_id = rawSeriesId?.trim() || undefined;
|
|
113
|
+
// ★ 이름을 id 로. **저장 전에** 한다 — 못 찾으면 아무것도 안 쓴 채 멈춘다.
|
|
114
|
+
if (series_name !== undefined && series_id === undefined) {
|
|
115
|
+
series_id = await resolveSeriesId(client, await resolveMyUsername(client, extra.signal), series_name, 'velog_create_draft', extra.signal);
|
|
116
|
+
}
|
|
88
117
|
if (series_id)
|
|
89
118
|
await assertOwnsSeries(client, series_id, 'velog_create_draft');
|
|
90
119
|
// ★ 초안은 is_private:true 라 벨로그 계수(is_private:false 만 셈)에
|
|
@@ -97,12 +126,14 @@ export function registerDraftTools(server, client) {
|
|
|
97
126
|
meta: {},
|
|
98
127
|
...DRAFT_ONLY, // ★ 마지막에 펼쳐서 위 값들이 덮어쓸 수 없게 한다
|
|
99
128
|
};
|
|
100
|
-
|
|
101
|
-
|
|
129
|
+
// ★ 본문에 그림이 있으면 썸네일로 쓴다. 결정 근거는 아래에서 그대로 알린다.
|
|
130
|
+
const thumb = chooseThumbnail(thumbnail, body);
|
|
131
|
+
if (thumb.url)
|
|
132
|
+
input['thumbnail'] = thumb.url;
|
|
102
133
|
if (series_id)
|
|
103
134
|
input['series_id'] = series_id;
|
|
104
135
|
// mutate 는 재시도하지 않는다 — 응답 유실 시 초안이 중복 생성된다.
|
|
105
|
-
const data = await client.mutate(MUTATION_WRITE_POST, { input });
|
|
136
|
+
const data = await client.mutate(MUTATION_WRITE_POST, { input }, { signal: extra.signal });
|
|
106
137
|
assertStayedDraft(data.writePost);
|
|
107
138
|
// ★ 벨로그는 임시저장 생성 시 series_id 를 조용히 버린다:
|
|
108
139
|
// // apps/server/src/services/PostApiService/index.mts
|
|
@@ -113,9 +144,11 @@ export function registerDraftTools(server, client) {
|
|
|
113
144
|
? '\n\n⚠️ 시리즈는 **적용되지 않았습니다.** 벨로그가 임시저장 생성 단계에서는' +
|
|
114
145
|
' series_id 를 무시합니다. 방금 만든 초안 id 로 velog_update_draft 를' +
|
|
115
146
|
' 한 번 호출하면 그때 실제로 붙습니다.'
|
|
116
|
-
:
|
|
117
|
-
|
|
118
|
-
|
|
147
|
+
: // ★ 힌트 조회가 실패해도 저장은 이미 끝났다 — 전부 삼킨다.
|
|
148
|
+
await seriesHintSafely(client, async () => data.writePost.user?.username ??
|
|
149
|
+
(await resolveMyUsername(client, extra.signal)), series_id, extra.signal);
|
|
150
|
+
return textResult(draftResult(data.writePost, '저장') + describeThumbnail(thumb) + seriesNote);
|
|
151
|
+
}));
|
|
119
152
|
server.registerTool('velog_update_draft', {
|
|
120
153
|
title: '벨로그 초안 수정',
|
|
121
154
|
description: '기존 초안을 **통째로 교체**한다. 부분 수정이 아니다. ' +
|
|
@@ -130,16 +163,21 @@ export function registerDraftTools(server, client) {
|
|
|
130
163
|
body: z.string().min(1).describe('본문 전체 (마크다운). 부분 수정이 아니라 교체다'),
|
|
131
164
|
tags: z.array(z.string()).default([]),
|
|
132
165
|
url_slug: z.string().optional(),
|
|
133
|
-
thumbnail:
|
|
134
|
-
.string()
|
|
135
|
-
.refine(isSafeImageUrl, 'http(s) 이미지 URL 만 허용합니다')
|
|
136
|
-
.optional(),
|
|
166
|
+
thumbnail: THUMBNAIL_FIELD,
|
|
137
167
|
series_id: z.string().optional(),
|
|
168
|
+
series_name: z
|
|
169
|
+
.string()
|
|
170
|
+
.min(1)
|
|
171
|
+
.optional()
|
|
172
|
+
.describe('시리즈 **이름**(id 대신). 저장 전에 내 시리즈에서 찾는다. ' +
|
|
173
|
+
'못 찾으면 저장하지 않고 목록을 알려준다'),
|
|
138
174
|
},
|
|
139
175
|
// ★ destructive 가 맞다. 생략 필드가 보존되지 않고 초기화된다 —
|
|
140
176
|
// MCP 명세상 destructiveHint:false 는 '추가만 한다'는 뜻이라 거짓이 된다.
|
|
141
177
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
142
|
-
}, async ({ id, title, body, tags, url_slug, thumbnail, series_id }) =>
|
|
178
|
+
}, async ({ id, title, body, tags, url_slug, thumbnail, series_id: rawSeriesId, series_name }, extra) =>
|
|
179
|
+
// ★ 같은 대상에 대한 쓰기는 줄을 세운다 — 이유는 src/serial.ts
|
|
180
|
+
serializeWrite(`post:${id}`, async () => {
|
|
143
181
|
client.requireAuth('velog_update_draft');
|
|
144
182
|
// ★ 두 가지를 확인한 뒤에야 수정한다.
|
|
145
183
|
// ① 내 글인가 — 벨로그 서버가 edit 에서 소유권을 안 본다(ownership.ts)
|
|
@@ -150,6 +188,10 @@ export function registerDraftTools(server, client) {
|
|
|
150
188
|
throw new Error(`id=${id} 인 글을 찾지 못했습니다. velog_list_drafts 로 id 를 확인하세요.`);
|
|
151
189
|
}
|
|
152
190
|
await assertOwned(client, before.post, 'velog_update_draft');
|
|
191
|
+
let series_id = rawSeriesId?.trim() || undefined;
|
|
192
|
+
if (series_name !== undefined && series_id === undefined) {
|
|
193
|
+
series_id = await resolveSeriesId(client, before.post.user?.username ?? (await resolveMyUsername(client, extra.signal)), series_name, 'velog_update_draft', extra.signal);
|
|
194
|
+
}
|
|
153
195
|
if (series_id)
|
|
154
196
|
await assertOwnsSeries(client, series_id, 'velog_update_draft');
|
|
155
197
|
if (before.post.is_temp !== true) {
|
|
@@ -168,13 +210,12 @@ export function registerDraftTools(server, client) {
|
|
|
168
210
|
meta: before.post.meta ?? {},
|
|
169
211
|
...DRAFT_ONLY,
|
|
170
212
|
};
|
|
171
|
-
|
|
172
|
-
|
|
213
|
+
const thumb = chooseThumbnail(thumbnail, body);
|
|
214
|
+
if (thumb.url)
|
|
215
|
+
input['thumbnail'] = thumb.url;
|
|
173
216
|
if (series_id)
|
|
174
217
|
input['series_id'] = series_id;
|
|
175
|
-
const data = await client.mutate(MUTATION_EDIT_POST, {
|
|
176
|
-
input,
|
|
177
|
-
});
|
|
218
|
+
const data = await client.mutate(MUTATION_EDIT_POST, { input }, { signal: extra.signal });
|
|
178
219
|
// ★ editPost 의 응답으로는 사후 상태를 알 수 없다. 공식 구현이
|
|
179
220
|
// `return { ...post, url_slug: data.url_slug }` 로 **갱신 전에 읽은**
|
|
180
221
|
// post 를 돌려주고 url_slug 만 덮기 때문이다
|
|
@@ -195,8 +236,14 @@ export function registerDraftTools(server, client) {
|
|
|
195
236
|
// ★ data.editPost 는 '갱신 전' 객체다(공식 서버가 그렇게 반환한다).
|
|
196
237
|
// 그걸로 문구를 만들면 제목을 바꿔도 옛 제목이 표시된다.
|
|
197
238
|
// 이미 재조회했으므로 그 결과를 얹는다.
|
|
198
|
-
|
|
199
|
-
|
|
239
|
+
// ★ 초안 수정은 series_id 가 실제로 먹는 경로다(생성과 달리). 그래서
|
|
240
|
+
// 여기서는 "안 넣었으면 무엇에 넣을 수 있는지"를 알리는 값이 크다.
|
|
241
|
+
const ownerName = before.post.user?.username;
|
|
242
|
+
const seriesNote = await seriesHintSafely(client, async () => ownerName ?? (await resolveMyUsername(client, extra.signal)), series_id, extra.signal);
|
|
243
|
+
return textResult(draftResult({ ...data.editPost, title: after.post.title ?? data.editPost.title ?? null }, '수정') +
|
|
244
|
+
describeThumbnail(thumb) +
|
|
245
|
+
seriesNote);
|
|
246
|
+
}));
|
|
200
247
|
server.registerTool('velog_list_drafts', {
|
|
201
248
|
title: '내 초안 목록',
|
|
202
249
|
description: '내 임시저장 글 목록. 초안을 이어 쓰거나 수정하기 전에 id 를 여기서 확인한다.',
|
package/dist/tools/drafts.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"drafts.js","sourceRoot":"","sources":["../../src/tools/drafts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1D,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AACvD,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAC7C,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAEhE,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"drafts.js","sourceRoot":"","sources":["../../src/tools/drafts.ts"],"names":[],"mappings":"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"}
|
package/dist/tools/export.js
CHANGED
|
@@ -15,6 +15,31 @@ import { slugify } from "../slug.js";
|
|
|
15
15
|
import { fetchAllPosts } from "./stats.js";
|
|
16
16
|
import { READ_ONLY } from "./posts.js";
|
|
17
17
|
import { resolveMyUsername } from "../me.js";
|
|
18
|
+
/** 벨로그 커넥션 풀(limit 5)을 배려하는 요청 간격. */
|
|
19
|
+
const GAP_MS = 250;
|
|
20
|
+
/**
|
|
21
|
+
* 한 호출에 쓸 수 있는 시간.
|
|
22
|
+
*
|
|
23
|
+
* ★ 왜 필요한가 — MCP SDK 의 클라이언트측 기본 요청 타임아웃은 60초다
|
|
24
|
+
* (shared/protocol.js: DEFAULT_REQUEST_TIMEOUT_MSEC = 60000).
|
|
25
|
+
* 그런데 이 도구는 글 한 편마다 상세를 따로 받는다. 실측(2026-08-07, 벨로그
|
|
26
|
+
* 공개 글 3편): 본문 왕복 평균 1,313ms. 간격 250ms 를 더하면 편당 약 1.5초다.
|
|
27
|
+
* **기본값 limit=50 이면 약 78초** — 60초를 넘는다. 즉 기본 인자로 부르면
|
|
28
|
+
* 클라이언트가 먼저 포기했고, 그런데도 루프는 계속 돌며 파일을 썼다.
|
|
29
|
+
* 화면에는 '실패'가 뜨는데 디스크에는 파일이 생기는 상태였다.
|
|
30
|
+
*
|
|
31
|
+
* 그래서 60초 안쪽에서 **우리가 먼저 멈추고 무엇을 했는지 보고한다.**
|
|
32
|
+
* 파일 이름이 글 순서로 정해지므로 같은 out_dir 로 다시 부르면 그대로 이어진다.
|
|
33
|
+
*/
|
|
34
|
+
const TIME_BUDGET_MS = 50_000;
|
|
35
|
+
/**
|
|
36
|
+
* 한 편을 더 시작하기 전에 남겨둬야 할 여유.
|
|
37
|
+
*
|
|
38
|
+
* 데드라인 직전에 루프로 들어가면 간격 250ms 에 상세 요청 한 번이 통째로 붙는다.
|
|
39
|
+
* 실측 왕복이 1.3초대이므로 3초면 정상 한 편은 넉넉히 들어가고, 안 들어갈 만하면
|
|
40
|
+
* 아예 시작하지 않아 예산을 지킨다.
|
|
41
|
+
*/
|
|
42
|
+
const PER_POST_RESERVE_MS = 3_000;
|
|
18
43
|
/** YAML 문자열 값. 따옴표·백슬래시를 이스케이프해 프론트매터가 깨지지 않게 한다. */
|
|
19
44
|
function yamlString(value) {
|
|
20
45
|
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
@@ -80,28 +105,48 @@ export function registerExportTools(server, client) {
|
|
|
80
105
|
// 벨로그 쪽 상태는 안 바뀌지만 로컬 파일은 '덮어쓴다'. MCP 명세상
|
|
81
106
|
// destructiveHint:false 는 '추가만 한다'는 뜻이라 거짓이 된다.
|
|
82
107
|
annotations: { ...READ_ONLY, readOnlyHint: false, destructiveHint: true },
|
|
83
|
-
}, async ({ username, out_dir, limit }) => {
|
|
84
|
-
|
|
108
|
+
}, async ({ username, out_dir, limit }, extra) => {
|
|
109
|
+
// ★ 예산 시계는 **핸들러가 시작하자마자** 켠다. 예전엔 목록 조회가 끝난
|
|
110
|
+
// 뒤에 켰는데, 그 앞의 계정 조회·목록 4페이지가 이미 몇 초를 먹는다.
|
|
111
|
+
// '한 호출에 쓸 수 있는 시간'을 재는데 그 앞을 빼면 예산이 아니다.
|
|
112
|
+
const deadline = Date.now() + TIME_BUDGET_MS;
|
|
113
|
+
const target = username ?? (await resolveMyUsername(client, extra.signal));
|
|
85
114
|
const dir = resolve(out_dir);
|
|
86
115
|
await mkdir(dir, { recursive: true });
|
|
87
116
|
const maxPages = Math.ceil(limit / 50);
|
|
88
|
-
const { posts } = await fetchAllPosts(client, target, maxPages);
|
|
117
|
+
const { posts } = await fetchAllPosts(client, target, maxPages, extra.signal);
|
|
89
118
|
const targets = posts.slice(0, limit);
|
|
90
119
|
if (targets.length === 0)
|
|
91
120
|
return textResult(`@${target} 의 공개 글이 없습니다.`);
|
|
92
121
|
const written = [];
|
|
93
122
|
const failed = [];
|
|
123
|
+
/** 다 못 돈 이유. 없으면 끝까지 돈 것이다. */
|
|
124
|
+
let stoppedBy = null;
|
|
94
125
|
for (const [index, summary] of targets.entries()) {
|
|
126
|
+
// ★ 클라이언트가 취소했으면 즉시 멈춘다. 여기서 안 보면 사용자는 이미
|
|
127
|
+
// 포기했는데 파일은 계속 쌓인다 — 화면과 디스크가 어긋난다.
|
|
128
|
+
if (extra.signal.aborted) {
|
|
129
|
+
stoppedBy = '취소';
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
// ★ 시간 예산. 이유는 TIME_BUDGET_MS 주석 참고.
|
|
133
|
+
// ★ 남은 시간이 '한 편 분량'도 안 되면 시작하지 않는다. 데드라인
|
|
134
|
+
// 직전에 들어가면 간격 250ms + 상세 요청(최대 재시도 예산)이
|
|
135
|
+
// 그대로 붙어 예산을 훌쩍 넘긴다.
|
|
136
|
+
if (Date.now() + PER_POST_RESERVE_MS >= deadline) {
|
|
137
|
+
stoppedBy = '시간';
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
95
140
|
// 벨로그 커넥션 풀(limit 5)을 배려한다. 몰아치면 상대가 먼저 죽는다.
|
|
96
141
|
if (index > 0)
|
|
97
|
-
await new Promise((r) => setTimeout(r,
|
|
142
|
+
await new Promise((r) => setTimeout(r, GAP_MS));
|
|
98
143
|
try {
|
|
99
144
|
// 목록에는 body 가 없으므로 한 편씩 상세를 받는다.
|
|
100
145
|
const data = await client.request(QUERY_POST,
|
|
101
146
|
// url_slug 가 없으면 id 로 조회한다.
|
|
102
147
|
summary.url_slug
|
|
103
148
|
? { input: { username: target, url_slug: summary.url_slug } }
|
|
104
|
-
: { input: { id: summary.id } });
|
|
149
|
+
: { input: { id: summary.id } }, { signal: extra.signal });
|
|
105
150
|
if (!data.post) {
|
|
106
151
|
failed.push(`${summary.title} (본문 조회 실패)`);
|
|
107
152
|
continue;
|
|
@@ -111,16 +156,32 @@ export function registerExportTools(server, client) {
|
|
|
111
156
|
written.push(name);
|
|
112
157
|
}
|
|
113
158
|
catch (error) {
|
|
159
|
+
if (extra.signal.aborted) {
|
|
160
|
+
stoppedBy = '취소';
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
114
163
|
const reason = error instanceof Error ? error.message : String(error);
|
|
115
164
|
failed.push(`${summary.title} — ${reason}`);
|
|
116
165
|
}
|
|
117
166
|
}
|
|
167
|
+
const done = written.length + failed.length;
|
|
118
168
|
const lines = [
|
|
119
169
|
`✅ ${written.length}편을 저장했습니다.`,
|
|
120
170
|
'',
|
|
121
171
|
`- 위치: ${dir}`,
|
|
122
172
|
`- 형식: YAML 프론트매터 + 마크다운 본문`,
|
|
123
173
|
];
|
|
174
|
+
if (stoppedBy !== null) {
|
|
175
|
+
const left = targets.length - done;
|
|
176
|
+
lines.push('', stoppedBy === '취소'
|
|
177
|
+
? `⏹️ 요청이 취소되어 ${done}/${targets.length}편에서 멈췄습니다.`
|
|
178
|
+
: `⏱️ ${TIME_BUDGET_MS / 1000}초 예산에 도달해 ${done}/${targets.length}편에서 멈췄습니다.`, done === 0
|
|
179
|
+
? '준비 조회(계정·글 목록)만으로 예산을 다 썼습니다. 벨로그가 느린 ' +
|
|
180
|
+
'상태일 수 있으니 잠시 뒤 다시 시도하세요 — limit 을 줄여도 소용없습니다.'
|
|
181
|
+
: `남은 ${left}편은 저장되지 않았습니다. **같은 out_dir 로 다시 부르면** ` +
|
|
182
|
+
'이미 받은 파일을 덮어쓰며 이어집니다(파일 이름이 글 순서로 정해집니다). ' +
|
|
183
|
+
'한 번에 끝내려면 limit 을 줄여 나눠 부르세요.');
|
|
184
|
+
}
|
|
124
185
|
if (failed.length > 0) {
|
|
125
186
|
lines.push('', `⚠️ ${failed.length}편 실패:`, ...failed.slice(0, 10).map((f) => ` - ${f}`));
|
|
126
187
|
if (failed.length > 10)
|
package/dist/tools/export.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"export.js","sourceRoot":"","sources":["../../src/tools/export.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"export.js","sourceRoot":"","sources":["../../src/tools/export.ts"],"names":[],"mappings":"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"}
|
package/dist/tools/images.js
CHANGED
|
@@ -25,6 +25,7 @@ import { z } from 'zod';
|
|
|
25
25
|
import { textResult } from "../format.js";
|
|
26
26
|
import { ICON_NAMES, TONE_NAMES, formatAudit, formatCoverAudit, renderCover, renderDiagram, } from "../render/index.js";
|
|
27
27
|
import { isHexColor } from "../render/tones.js";
|
|
28
|
+
import { makeSerializer } from "../serial.js";
|
|
28
29
|
/** 우리가 올리는 것 — 서버 상한(30MB)보다 낮게 잡는다. */
|
|
29
30
|
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
30
31
|
/**
|
|
@@ -321,6 +322,17 @@ const planeSchema = z.object({
|
|
|
321
322
|
function markdown(alt, url) {
|
|
322
323
|
return ``;
|
|
323
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* 업로드도 줄을 세운다.
|
|
327
|
+
*
|
|
328
|
+
* ★ 파일당 10MB 상한은 있는데 **동시성 상한이 없었다.** 한 요청이 10MB 버퍼와
|
|
329
|
+
* 그 multipart 사본을 최대 60초 들고 있으므로, 병렬 호출 수만큼 그대로 곱해진다.
|
|
330
|
+
* 렌더는 같은 이유로 이미 줄을 세워 크롬 메모리를 1GB 에 묶어뒀는데
|
|
331
|
+
* 업로드만 빠져 있었다.
|
|
332
|
+
* ★ 벨로그 쪽 사정도 같은 방향이다 — 1분 20건을 넘기면 계정을 막는다
|
|
333
|
+
* (ImageService.detectAbuse). 몰아치지 않는 편이 안전하다.
|
|
334
|
+
*/
|
|
335
|
+
const serializeUpload = makeSerializer();
|
|
324
336
|
export function registerImageTools(server, client) {
|
|
325
337
|
/** 렌더 결과를 올리고 결과 문구를 만든다. 감사에 걸린 게 있으면 올리지 않는다. */
|
|
326
338
|
async function finish(args) {
|
|
@@ -360,11 +372,19 @@ export function registerImageTools(server, client) {
|
|
|
360
372
|
return lines;
|
|
361
373
|
}
|
|
362
374
|
if (args.upload) {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
375
|
+
// ★ 파일 읽기도 **줄 안에서** 한다. 밖에서 읽으면 대기 중인 요청마다
|
|
376
|
+
// 10MB 버퍼가 메모리에 올라간 채 순서를 기다린다 — 줄을 세운 이유
|
|
377
|
+
// (메모리 상한)가 그대로 사라진다. 코덱스 교차검증에서 잡았다.
|
|
378
|
+
const url = await serializeUpload(async () => {
|
|
379
|
+
// ★ 줄에서 기다리는 동안 취소됐을 수 있다. 읽기 전에 확인한다 —
|
|
380
|
+
// 안 그러면 이미 포기한 요청이 파일부터 읽고 버퍼를 잡는다.
|
|
381
|
+
args.signal.throwIfAborted();
|
|
382
|
+
const bytes = new Uint8Array(await readFile(args.pngPath));
|
|
383
|
+
const uploadOptions = args.postId
|
|
384
|
+
? { type: 'post', contentType: 'image/png', refId: args.postId, signal: args.signal }
|
|
385
|
+
: { type: 'post', contentType: 'image/png', signal: args.signal };
|
|
386
|
+
return client.uploadImage(bytes, `${basename(args.pngPath, '.png')}.png`, uploadOptions);
|
|
387
|
+
});
|
|
368
388
|
lines.push('본문에 붙여넣을 마크다운:');
|
|
369
389
|
lines.push('');
|
|
370
390
|
lines.push(markdown(args.alt, url));
|
|
@@ -429,7 +449,7 @@ export function registerImageTools(server, client) {
|
|
|
429
449
|
.describe('붙일 글 id. 주면 벨로그가 내 글인지 확인한 뒤 받는다'),
|
|
430
450
|
},
|
|
431
451
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
432
|
-
}, async (args) => {
|
|
452
|
+
}, async (args, extra) => {
|
|
433
453
|
if (args.upload)
|
|
434
454
|
client.requireAuth('velog_render_diagram');
|
|
435
455
|
const spec = {
|
|
@@ -466,6 +486,7 @@ export function registerImageTools(server, client) {
|
|
|
466
486
|
alt: args.alt ?? args.title,
|
|
467
487
|
...(args.post_id ? { postId: args.post_id } : {}),
|
|
468
488
|
what: '다이어그램',
|
|
489
|
+
signal: extra.signal,
|
|
469
490
|
})).join('\n'));
|
|
470
491
|
});
|
|
471
492
|
server.registerTool('velog_render_cover', {
|
|
@@ -484,7 +505,7 @@ export function registerImageTools(server, client) {
|
|
|
484
505
|
post_id: z.string().max(64).optional(),
|
|
485
506
|
},
|
|
486
507
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
487
|
-
}, async (args) => {
|
|
508
|
+
}, async (args, extra) => {
|
|
488
509
|
if (args.upload)
|
|
489
510
|
client.requireAuth('velog_render_cover');
|
|
490
511
|
const spec = { title: args.title };
|
|
@@ -512,6 +533,7 @@ export function registerImageTools(server, client) {
|
|
|
512
533
|
alt: args.title,
|
|
513
534
|
...(args.post_id ? { postId: args.post_id } : {}),
|
|
514
535
|
what: '표지',
|
|
536
|
+
signal: extra.signal,
|
|
515
537
|
})).join('\n'));
|
|
516
538
|
});
|
|
517
539
|
server.registerTool('velog_upload_image', {
|
|
@@ -530,7 +552,7 @@ export function registerImageTools(server, client) {
|
|
|
530
552
|
post_id: z.string().max(64).optional().describe('붙일 글 id (서버가 소유권을 확인한다)'),
|
|
531
553
|
},
|
|
532
554
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
533
|
-
}, async (args) => {
|
|
555
|
+
}, async (args, extra) => {
|
|
534
556
|
client.requireAuth('velog_upload_image');
|
|
535
557
|
// ★ 문자열 그대로 비교하면 `/tmp/x/./a.png` 하나로 빠져나간다.
|
|
536
558
|
// 경로를 정규화하고, 심볼릭 링크까지 푼 형태로도 본다.
|
|
@@ -538,12 +560,26 @@ export function registerImageTools(server, client) {
|
|
|
538
560
|
throw new Error('이 PNG 는 이 서버의 자가감사에서 떨어진 산출물입니다 — 올릴 수 없습니다.\n' +
|
|
539
561
|
'그림을 고쳐 다시 그리세요 (velog_render_diagram).');
|
|
540
562
|
}
|
|
541
|
-
const { bytes, kind } = await readImageFile(args.path);
|
|
542
563
|
const name = basename(args.path);
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
564
|
+
// ★ 읽기부터 줄 안이다 — 이유는 위 finish() 의 주석과 같다.
|
|
565
|
+
// 대기 중인 요청이 10MB 씩 들고 서 있으면 상한을 둔 의미가 없다.
|
|
566
|
+
const { url, kind, bytes } = await serializeUpload(async () => {
|
|
567
|
+
extra.signal.throwIfAborted();
|
|
568
|
+
// ★ 거부 목록은 줄 밖에서 한 번 봤지만, 기다리는 사이에 이 PNG 가
|
|
569
|
+
// 감사에서 떨어져 목록에 오를 수 있다. 읽기 직전에 다시 본다.
|
|
570
|
+
if (await isRejected(args.path)) {
|
|
571
|
+
throw new Error('이 PNG 는 이 서버의 자가감사에서 떨어진 산출물입니다 — 올릴 수 없습니다.');
|
|
572
|
+
}
|
|
573
|
+
const read = await readImageFile(args.path);
|
|
574
|
+
const uploadOptions = args.post_id
|
|
575
|
+
? { type: args.type, contentType: read.kind.mime, refId: args.post_id, signal: extra.signal }
|
|
576
|
+
: { type: args.type, contentType: read.kind.mime, signal: extra.signal };
|
|
577
|
+
return {
|
|
578
|
+
url: await client.uploadImage(read.bytes, name, uploadOptions),
|
|
579
|
+
kind: read.kind,
|
|
580
|
+
bytes: read.bytes,
|
|
581
|
+
};
|
|
582
|
+
});
|
|
547
583
|
return textResult([
|
|
548
584
|
`✅ 올렸습니다 — ${kind.mime} · ${(bytes.length / 1024).toFixed(0)}KB`,
|
|
549
585
|
'',
|