@milcho0604/velog-mcp 0.4.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/LICENSE +21 -0
- package/README.ko.md +366 -0
- package/README.md +381 -0
- package/dist/auth.d.ts +57 -0
- package/dist/auth.js +124 -0
- package/dist/auth.js.map +1 -0
- package/dist/capabilities.d.ts +50 -0
- package/dist/capabilities.js +60 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/client.d.ts +113 -0
- package/dist/client.js +322 -0
- package/dist/client.js.map +1 -0
- package/dist/format.d.ts +31 -0
- package/dist/format.js +66 -0
- package/dist/format.js.map +1 -0
- package/dist/graphql.d.ts +29 -0
- package/dist/graphql.js +82 -0
- package/dist/graphql.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +149 -0
- package/dist/index.js.map +1 -0
- package/dist/me.d.ts +23 -0
- package/dist/me.js +35 -0
- package/dist/me.js.map +1 -0
- package/dist/ownership.d.ts +42 -0
- package/dist/ownership.js +62 -0
- package/dist/ownership.js.map +1 -0
- package/dist/plugin-env.d.ts +67 -0
- package/dist/plugin-env.js +102 -0
- package/dist/plugin-env.js.map +1 -0
- package/dist/ratelimit.d.ts +48 -0
- package/dist/ratelimit.js +79 -0
- package/dist/ratelimit.js.map +1 -0
- package/dist/render/chrome.d.ts +77 -0
- package/dist/render/chrome.js +287 -0
- package/dist/render/chrome.js.map +1 -0
- package/dist/render/cover.d.ts +29 -0
- package/dist/render/cover.js +195 -0
- package/dist/render/cover.js.map +1 -0
- package/dist/render/icons.d.ts +22 -0
- package/dist/render/icons.js +158 -0
- package/dist/render/icons.js.map +1 -0
- package/dist/render/index.d.ts +32 -0
- package/dist/render/index.js +137 -0
- package/dist/render/index.js.map +1 -0
- package/dist/render/page.d.ts +89 -0
- package/dist/render/page.js +761 -0
- package/dist/render/page.js.map +1 -0
- package/dist/render/tones.d.ts +30 -0
- package/dist/render/tones.js +46 -0
- package/dist/render/tones.js.map +1 -0
- package/dist/slug.d.ts +42 -0
- package/dist/slug.js +79 -0
- package/dist/slug.js.map +1 -0
- package/dist/tools/discover.d.ts +6 -0
- package/dist/tools/discover.js +106 -0
- package/dist/tools/discover.js.map +1 -0
- package/dist/tools/drafts.d.ts +23 -0
- package/dist/tools/drafts.js +227 -0
- package/dist/tools/drafts.js.map +1 -0
- package/dist/tools/export.d.ts +21 -0
- package/dist/tools/export.js +132 -0
- package/dist/tools/export.js.map +1 -0
- package/dist/tools/images.d.ts +34 -0
- package/dist/tools/images.js +556 -0
- package/dist/tools/images.js.map +1 -0
- package/dist/tools/posts.d.ts +14 -0
- package/dist/tools/posts.js +82 -0
- package/dist/tools/posts.js.map +1 -0
- package/dist/tools/profile-edit.d.ts +15 -0
- package/dist/tools/profile-edit.js +216 -0
- package/dist/tools/profile-edit.js.map +1 -0
- package/dist/tools/profile.d.ts +9 -0
- package/dist/tools/profile.js +133 -0
- package/dist/tools/profile.js.map +1 -0
- package/dist/tools/publish.d.ts +16 -0
- package/dist/tools/publish.js +424 -0
- package/dist/tools/publish.js.map +1 -0
- package/dist/tools/stats.d.ts +32 -0
- package/dist/tools/stats.js +154 -0
- package/dist/tools/stats.js.map +1 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/docs/PRD.md +146 -0
- package/docs/api-reference.md +329 -0
- package/docs/architecture.md +112 -0
- package/docs/decisions/0001-why-build-our-own.md +89 -0
- package/docs/decisions/0002-draft-only-write.md +84 -0
- package/docs/decisions/0003-token-env-only.md +109 -0
- package/docs/decisions/0004-capability-model.md +123 -0
- package/docs/decisions/0005-render-in-server.md +117 -0
- package/docs/decisions/0006-ship-as-plugin.md +532 -0
- package/docs/security.md +384 -0
- package/docs/tools.md +404 -0
- package/npm-shrinkwrap.json +2345 -0
- package/package.json +61 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"capabilities.js","sourceRoot":"","sources":["../src/capabilities.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAkBH,2CAA2C;AAC3C,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,CAAC,CAAC,CAAC;AAEnD,SAAS,IAAI,CAAC,KAAyB;IACtC,OAAO,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC;AACvD,CAAC;AAED,MAAM,UAAU,gBAAgB,CAC/B,MAAyB,OAAO,CAAC,GAAG;IAEpC,OAAO;QACN,aAAa,EAAE,IAAI,CAAC,GAAG,CAAC,oBAAoB,CAAC,CAAC;QAC9C,WAAW,EAAE,IAAI,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC;KAC7C,CAAC;AACH,CAAC;AAED,oDAAoD;AACpD,MAAM,UAAU,oBAAoB,CAAC,YAA0B;IAC9D,MAAM,KAAK,GAAG;QACb,YAAY,CAAC,aAAa;YACzB,CAAC,CAAC,yBAAyB;YAC3B,CAAC,CAAC,kBAAkB;KACrB,CAAC;IACF,IAAI,YAAY,CAAC,WAAW;QAAE,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAEnD,MAAM,GAAG,GAAa,EAAE,CAAC;IACzB,IAAI,CAAC,YAAY,CAAC,aAAa;QAAE,GAAG,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAC;IACtE,IAAI,CAAC,YAAY,CAAC,WAAW;QAAE,GAAG,CAAC,IAAI,CAAC,4BAA4B,CAAC,CAAC;IAEtE,OAAO,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;AAC3E,CAAC"}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 벨로그 GraphQL 클라이언트.
|
|
3
|
+
*
|
|
4
|
+
* Node 24 내장 fetch 만 쓴다 — HTTP 라이브러리를 추가하지 않는다.
|
|
5
|
+
* 접속하는 호스트는 v3.velog.io 하나뿐이다 (docs/security.md).
|
|
6
|
+
*/
|
|
7
|
+
import { type AuthState } from './auth.ts';
|
|
8
|
+
export declare const VELOG_ENDPOINT = "https://v3.velog.io/graphql";
|
|
9
|
+
/**
|
|
10
|
+
* 이미지 업로드만 GraphQL 이 아니다.
|
|
11
|
+
*
|
|
12
|
+
* v3 의 mutation 23개 어디에도 업로드가 없다. 벨로그 서버는 파일을 fastify REST
|
|
13
|
+
* 라우트로 받는다 — `routes/index.mts` 가 `/api` 아래에 `/files` 를 붙이고,
|
|
14
|
+
* `routes/files/index.mts` 가 다시 `/v3` 를 붙여 아래 경로가 된다.
|
|
15
|
+
* 폼 필드 이름이 `image` 인 것도 서버 쪽 `multer.single('image')` 에서 온다.
|
|
16
|
+
*/
|
|
17
|
+
export declare const VELOG_UPLOAD_ENDPOINT = "https://v3.velog.io/api/files/v3/upload";
|
|
18
|
+
/**
|
|
19
|
+
* 벨로그가 비정상 응답을 줬을 때. 메시지는 마스킹을 거친 뒤 담긴다.
|
|
20
|
+
*
|
|
21
|
+
* ★ 파라미터 프로퍼티(`constructor(readonly x: T)`)를 쓰지 않는다.
|
|
22
|
+
* Node 의 타입 스트리핑은 '지우기만' 하지 코드를 생성하지 않아
|
|
23
|
+
* ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX 로 죽는다. 같은 이유로 이 레포는
|
|
24
|
+
* enum·namespace·데코레이터도 쓰지 않는다. (docs/architecture.md 참고)
|
|
25
|
+
*/
|
|
26
|
+
export interface VelogApiErrorDetail {
|
|
27
|
+
readonly status?: number;
|
|
28
|
+
/** 코드만 보관한다. 원본 응답에는 토큰이 섞일 수 있다. */
|
|
29
|
+
readonly graphqlErrorCodes?: readonly string[];
|
|
30
|
+
/**
|
|
31
|
+
* 네트워크 오류의 원인 표식 (ECONNRESET·TimeoutError 등).
|
|
32
|
+
* ★ Node 의 fetch 실패는 TypeError('fetch failed') 로 오고 진짜 원인은
|
|
33
|
+
* cause 체인에 있다. message 만 뽑으면 'fetch failed' 만 남아 재시도 판정이
|
|
34
|
+
* 전부 실패한다 — 실측으로 확인함.
|
|
35
|
+
*/
|
|
36
|
+
readonly networkCodes?: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
/** cause 체인을 훑어 code·name 을 모은다. 토큰이 섞일 수 있는 message 는 담지 않는다. */
|
|
39
|
+
export declare function collectCauseCodes(error: unknown, depth?: number): string[];
|
|
40
|
+
export declare class VelogApiError extends Error {
|
|
41
|
+
readonly detail: VelogApiErrorDetail | undefined;
|
|
42
|
+
constructor(message: string, detail?: VelogApiErrorDetail);
|
|
43
|
+
}
|
|
44
|
+
export interface ClientOptions {
|
|
45
|
+
readonly auth: AuthState;
|
|
46
|
+
readonly endpoint?: string;
|
|
47
|
+
readonly uploadEndpoint?: string;
|
|
48
|
+
readonly timeoutMs?: number;
|
|
49
|
+
readonly fetchImpl?: typeof fetch;
|
|
50
|
+
readonly maxRetries?: number;
|
|
51
|
+
/** 테스트에서 대기를 건너뛰기 위해 주입한다. */
|
|
52
|
+
readonly sleepImpl?: (ms: number) => Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* 인증 상태로 비-벨로그 엔드포인트에 요청하는 것을 허용한다.
|
|
55
|
+
* 가짜 fetch 로 도구를 검사하는 테스트 전용이며, 운영 경로에서는 절대 켜지 않는다.
|
|
56
|
+
*/
|
|
57
|
+
readonly allowInsecureEndpoint?: boolean;
|
|
58
|
+
}
|
|
59
|
+
export declare function isTransient(error: unknown): boolean;
|
|
60
|
+
export declare class VelogClient {
|
|
61
|
+
#private;
|
|
62
|
+
constructor(options: ClientOptions);
|
|
63
|
+
get isAuthenticated(): boolean;
|
|
64
|
+
/** 인증이 필요한 도구가 먼저 호출한다. */
|
|
65
|
+
requireAuth(toolName: string): void;
|
|
66
|
+
/**
|
|
67
|
+
* 일시적 실패는 지수 백오프로 다시 친다. 영구 오류(인증 만료·잘못된 질의)는
|
|
68
|
+
* 즉시 던진다 — 재시도해봤자 같은 답이고 사용자만 기다린다.
|
|
69
|
+
*
|
|
70
|
+
* ★ mutation 은 재시도하지 않는다. 멱등하지 않기 때문이다.
|
|
71
|
+
* 서버가 글을 만든 뒤 응답만 유실되면 재시도가 초안을 하나 더 만든다.
|
|
72
|
+
* 그냥 중복이 아니라 실제 피해로 이어진다 — mutate() 주석 참고.
|
|
73
|
+
*/
|
|
74
|
+
request<T>(query: string, variables?: Record<string, unknown>): Promise<T>;
|
|
75
|
+
/**
|
|
76
|
+
* 쓰기 전용 — **재시도하지 않는다.**
|
|
77
|
+
*
|
|
78
|
+
* 벨로그는 최근 5분의 `is_private:false` 글을 세는데 `is_temp` 를 구분하지
|
|
79
|
+
* 않는다. 10개를 넘으면 최근 5분의 글을 **전부** `is_private:true` 로 바꾼다:
|
|
80
|
+
*
|
|
81
|
+
* // apps/server/src/services/PostApiService/index.mts
|
|
82
|
+
* const recentPostCount = await db.post.count({
|
|
83
|
+
* where: { fk_user_id, is_private: false, released_at: { gt: 5분전 } } })
|
|
84
|
+
* if (recentPostCount < 10) return false
|
|
85
|
+
* await db.post.updateMany({
|
|
86
|
+
* where: { fk_user_id, released_at: { gt: 5분전 } }, // is_private 필터 없음
|
|
87
|
+
* data: { is_private: true } })
|
|
88
|
+
*
|
|
89
|
+
* 초안도 Prisma 기본값으로 `released_at = now()` 가 붙으므로(schema.prisma)
|
|
90
|
+
* 이 카운트에 들어간다. 즉 초안을 몰아 만들면 **같은 시간대에 발행한 진짜 글이
|
|
91
|
+
* 비공개로 내려간다.** 응답 유실로 인한 자동 재시도가 이 한계를 앞당길 수 있어
|
|
92
|
+
* 쓰기는 한 번만 친다. 실패하면 사용자가 상태를 확인하고 다시 부르게 한다.
|
|
93
|
+
*/
|
|
94
|
+
mutate<T>(query: string, variables?: Record<string, unknown>): Promise<T>;
|
|
95
|
+
/**
|
|
96
|
+
* 이미지 업로드. **재시도하지 않는다.**
|
|
97
|
+
*
|
|
98
|
+
* 벨로그는 업로드 횟수를 사용자 단위로 센다 (`ImageService.detectAbuse`):
|
|
99
|
+
* 1시간 100건 초과 또는 1분 20건 이상이면 그 계정을 막고 슬랙으로 알린다.
|
|
100
|
+
* 응답만 유실됐을 때 자동 재시도하면 실제로는 두 번 올라가 이 한도를 앞당긴다.
|
|
101
|
+
* mutate() 와 같은 이유로 한 번만 친다.
|
|
102
|
+
*
|
|
103
|
+
* `refId` 를 주면 서버가 "그 글이 내 글인지" 확인한다 (남의 글이면 403).
|
|
104
|
+
* 벨로그에 몇 없는 실제 소유권 검사라 쓸 수 있으면 쓴다.
|
|
105
|
+
*
|
|
106
|
+
* @returns 업로드된 이미지의 공개 URL (`https://velog.velcdn.com/...`)
|
|
107
|
+
*/
|
|
108
|
+
uploadImage(bytes: Uint8Array, filename: string, options: {
|
|
109
|
+
readonly type: 'post' | 'profile';
|
|
110
|
+
readonly contentType: string;
|
|
111
|
+
readonly refId?: string;
|
|
112
|
+
}): Promise<string>;
|
|
113
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 벨로그 GraphQL 클라이언트.
|
|
3
|
+
*
|
|
4
|
+
* Node 24 내장 fetch 만 쓴다 — HTTP 라이브러리를 추가하지 않는다.
|
|
5
|
+
* 접속하는 호스트는 v3.velog.io 하나뿐이다 (docs/security.md).
|
|
6
|
+
*/
|
|
7
|
+
import { AuthRequiredError, TokenStore, buildCookieHeader, parseSetCookie, } from "./auth.js";
|
|
8
|
+
export const VELOG_ENDPOINT = 'https://v3.velog.io/graphql';
|
|
9
|
+
/**
|
|
10
|
+
* 이미지 업로드만 GraphQL 이 아니다.
|
|
11
|
+
*
|
|
12
|
+
* v3 의 mutation 23개 어디에도 업로드가 없다. 벨로그 서버는 파일을 fastify REST
|
|
13
|
+
* 라우트로 받는다 — `routes/index.mts` 가 `/api` 아래에 `/files` 를 붙이고,
|
|
14
|
+
* `routes/files/index.mts` 가 다시 `/v3` 를 붙여 아래 경로가 된다.
|
|
15
|
+
* 폼 필드 이름이 `image` 인 것도 서버 쪽 `multer.single('image')` 에서 온다.
|
|
16
|
+
*/
|
|
17
|
+
export const VELOG_UPLOAD_ENDPOINT = 'https://v3.velog.io/api/files/v3/upload';
|
|
18
|
+
/** cause 체인을 훑어 code·name 을 모은다. 토큰이 섞일 수 있는 message 는 담지 않는다. */
|
|
19
|
+
export function collectCauseCodes(error, depth = 5) {
|
|
20
|
+
const codes = [];
|
|
21
|
+
let current = error;
|
|
22
|
+
for (let i = 0; i < depth && current; i++) {
|
|
23
|
+
if (typeof current !== 'object')
|
|
24
|
+
break;
|
|
25
|
+
const node = current;
|
|
26
|
+
if (typeof node.name === 'string')
|
|
27
|
+
codes.push(node.name);
|
|
28
|
+
if (typeof node.code === 'string')
|
|
29
|
+
codes.push(node.code);
|
|
30
|
+
current = node.cause;
|
|
31
|
+
}
|
|
32
|
+
return codes;
|
|
33
|
+
}
|
|
34
|
+
export class VelogApiError extends Error {
|
|
35
|
+
detail;
|
|
36
|
+
constructor(message, detail) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = 'VelogApiError';
|
|
39
|
+
this.detail = detail;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 벨로그는 Prisma 커넥션 풀이 작다 (connection limit 5 / timeout 10s).
|
|
44
|
+
* 연속 호출하면 아래 오류가 실제로 뜬다 — 우리 잘못이 아니라 상대 쪽 포화다.
|
|
45
|
+
*
|
|
46
|
+
* Timed out fetching a new connection from the connection pool
|
|
47
|
+
*
|
|
48
|
+
* 2026-07-30 실측: velog_export_posts 처럼 순차 요청이 많은 도구에서 재현.
|
|
49
|
+
* 영구 실패가 아니므로 잠깐 쉬었다 다시 친다.
|
|
50
|
+
*/
|
|
51
|
+
const TRANSIENT_CODES = new Set([
|
|
52
|
+
'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE', 'EAI_AGAIN',
|
|
53
|
+
'ENOTFOUND', 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT',
|
|
54
|
+
'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT',
|
|
55
|
+
'TimeoutError', 'AbortError',
|
|
56
|
+
]);
|
|
57
|
+
export function isTransient(error) {
|
|
58
|
+
if (!(error instanceof VelogApiError))
|
|
59
|
+
return false;
|
|
60
|
+
const status = error.detail?.status;
|
|
61
|
+
if (status !== undefined && status >= 500)
|
|
62
|
+
return true;
|
|
63
|
+
// 4xx 는 다시 쳐도 같은 답이다.
|
|
64
|
+
if (status !== undefined && status < 500)
|
|
65
|
+
return false;
|
|
66
|
+
// ★ 네트워크 오류는 message 가 아니라 cause 체인의 code 로 판정한다.
|
|
67
|
+
for (const code of error.detail?.networkCodes ?? []) {
|
|
68
|
+
if (TRANSIENT_CODES.has(code))
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
return /connection pool|timed out|timeout|ETIMEDOUT|ECONNRESET|socket hang up/i.test(error.message);
|
|
72
|
+
}
|
|
73
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
74
|
+
/** 인증 만료 안내. HTTP 401 경로와 GraphQL errors 경로 둘 다에서 쓴다. */
|
|
75
|
+
const AUTH_HINT = ' — access_token 이 만료됐을 수 있습니다(유효기간 1시간). 새 토큰으로 갱신하세요.';
|
|
76
|
+
export class VelogClient {
|
|
77
|
+
#tokens;
|
|
78
|
+
#endpoint;
|
|
79
|
+
#uploadEndpoint;
|
|
80
|
+
#timeoutMs;
|
|
81
|
+
#fetch;
|
|
82
|
+
#maxRetries;
|
|
83
|
+
#sleep;
|
|
84
|
+
#allowInsecureEndpoint;
|
|
85
|
+
constructor(options) {
|
|
86
|
+
this.#tokens = new TokenStore(options.auth);
|
|
87
|
+
this.#endpoint = options.endpoint ?? VELOG_ENDPOINT;
|
|
88
|
+
this.#uploadEndpoint = options.uploadEndpoint ?? VELOG_UPLOAD_ENDPOINT;
|
|
89
|
+
this.#timeoutMs = options.timeoutMs ?? 20_000;
|
|
90
|
+
this.#fetch = options.fetchImpl ?? fetch;
|
|
91
|
+
this.#maxRetries = options.maxRetries ?? 2;
|
|
92
|
+
this.#sleep = options.sleepImpl ?? defaultSleep;
|
|
93
|
+
this.#allowInsecureEndpoint = options.allowInsecureEndpoint ?? false;
|
|
94
|
+
}
|
|
95
|
+
get isAuthenticated() {
|
|
96
|
+
return this.#tokens.isAuthenticated;
|
|
97
|
+
}
|
|
98
|
+
/** 인증이 필요한 도구가 먼저 호출한다. */
|
|
99
|
+
requireAuth(toolName) {
|
|
100
|
+
if (!this.#tokens.isAuthenticated)
|
|
101
|
+
throw new AuthRequiredError(toolName);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* 일시적 실패는 지수 백오프로 다시 친다. 영구 오류(인증 만료·잘못된 질의)는
|
|
105
|
+
* 즉시 던진다 — 재시도해봤자 같은 답이고 사용자만 기다린다.
|
|
106
|
+
*
|
|
107
|
+
* ★ mutation 은 재시도하지 않는다. 멱등하지 않기 때문이다.
|
|
108
|
+
* 서버가 글을 만든 뒤 응답만 유실되면 재시도가 초안을 하나 더 만든다.
|
|
109
|
+
* 그냥 중복이 아니라 실제 피해로 이어진다 — mutate() 주석 참고.
|
|
110
|
+
*/
|
|
111
|
+
async request(query, variables = {}) {
|
|
112
|
+
let lastError;
|
|
113
|
+
for (let attempt = 0; attempt <= this.#maxRetries; attempt++) {
|
|
114
|
+
try {
|
|
115
|
+
return await this.#requestOnce(query, variables);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
lastError = error;
|
|
119
|
+
if (!isTransient(error) || attempt === this.#maxRetries)
|
|
120
|
+
throw error;
|
|
121
|
+
await this.#sleep(500 * 2 ** attempt); // 500ms → 1s
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
throw lastError;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* 쓰기 전용 — **재시도하지 않는다.**
|
|
128
|
+
*
|
|
129
|
+
* 벨로그는 최근 5분의 `is_private:false` 글을 세는데 `is_temp` 를 구분하지
|
|
130
|
+
* 않는다. 10개를 넘으면 최근 5분의 글을 **전부** `is_private:true` 로 바꾼다:
|
|
131
|
+
*
|
|
132
|
+
* // apps/server/src/services/PostApiService/index.mts
|
|
133
|
+
* const recentPostCount = await db.post.count({
|
|
134
|
+
* where: { fk_user_id, is_private: false, released_at: { gt: 5분전 } } })
|
|
135
|
+
* if (recentPostCount < 10) return false
|
|
136
|
+
* await db.post.updateMany({
|
|
137
|
+
* where: { fk_user_id, released_at: { gt: 5분전 } }, // is_private 필터 없음
|
|
138
|
+
* data: { is_private: true } })
|
|
139
|
+
*
|
|
140
|
+
* 초안도 Prisma 기본값으로 `released_at = now()` 가 붙으므로(schema.prisma)
|
|
141
|
+
* 이 카운트에 들어간다. 즉 초안을 몰아 만들면 **같은 시간대에 발행한 진짜 글이
|
|
142
|
+
* 비공개로 내려간다.** 응답 유실로 인한 자동 재시도가 이 한계를 앞당길 수 있어
|
|
143
|
+
* 쓰기는 한 번만 친다. 실패하면 사용자가 상태를 확인하고 다시 부르게 한다.
|
|
144
|
+
*/
|
|
145
|
+
async mutate(query, variables = {}) {
|
|
146
|
+
return this.#requestOnce(query, variables);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* 이미지 업로드. **재시도하지 않는다.**
|
|
150
|
+
*
|
|
151
|
+
* 벨로그는 업로드 횟수를 사용자 단위로 센다 (`ImageService.detectAbuse`):
|
|
152
|
+
* 1시간 100건 초과 또는 1분 20건 이상이면 그 계정을 막고 슬랙으로 알린다.
|
|
153
|
+
* 응답만 유실됐을 때 자동 재시도하면 실제로는 두 번 올라가 이 한도를 앞당긴다.
|
|
154
|
+
* mutate() 와 같은 이유로 한 번만 친다.
|
|
155
|
+
*
|
|
156
|
+
* `refId` 를 주면 서버가 "그 글이 내 글인지" 확인한다 (남의 글이면 403).
|
|
157
|
+
* 벨로그에 몇 없는 실제 소유권 검사라 쓸 수 있으면 쓴다.
|
|
158
|
+
*
|
|
159
|
+
* @returns 업로드된 이미지의 공개 URL (`https://velog.velcdn.com/...`)
|
|
160
|
+
*/
|
|
161
|
+
async uploadImage(bytes, filename, options) {
|
|
162
|
+
const auth = this.#tokens.state;
|
|
163
|
+
if (auth.kind !== 'authenticated')
|
|
164
|
+
throw new AuthRequiredError('velog_upload_image');
|
|
165
|
+
// GraphQL 경로와 같은 규율 — 자격증명은 벨로그 정규 주소로만 나간다.
|
|
166
|
+
const headers = { Accept: 'application/json' };
|
|
167
|
+
if (this.#uploadEndpoint === VELOG_UPLOAD_ENDPOINT) {
|
|
168
|
+
headers['Cookie'] = buildCookieHeader(auth.credentials);
|
|
169
|
+
}
|
|
170
|
+
else if (!this.#allowInsecureEndpoint) {
|
|
171
|
+
throw new VelogApiError(`이미지는 ${VELOG_UPLOAD_ENDPOINT} 로만 보낼 수 있습니다. ` +
|
|
172
|
+
'다른 주소로는 파일도 토큰도 보내지 않습니다.');
|
|
173
|
+
}
|
|
174
|
+
const form = new FormData();
|
|
175
|
+
form.append('type', options.type);
|
|
176
|
+
if (options.refId)
|
|
177
|
+
form.append('ref_id', options.refId);
|
|
178
|
+
// ★ 필드 이름은 반드시 'image'. 서버가 multer.single('image') 로 받는다.
|
|
179
|
+
// Content-Type 헤더는 손대지 않는다 — fetch 가 multipart 경계를 붙인다.
|
|
180
|
+
form.append('image', new Blob([bytes], { type: options.contentType }), filename);
|
|
181
|
+
let response;
|
|
182
|
+
try {
|
|
183
|
+
response = await this.#fetch(this.#uploadEndpoint, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers,
|
|
186
|
+
body: form,
|
|
187
|
+
// 이미지는 GraphQL 질의보다 오래 걸린다. 최소 60초는 준다.
|
|
188
|
+
signal: AbortSignal.timeout(Math.max(this.#timeoutMs, 60_000)),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
catch (cause) {
|
|
192
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
193
|
+
const codes = collectCauseCodes(cause);
|
|
194
|
+
// ★ '실패'라고 단정하면 안 된다. 서버가 파일을 받아 저장한 뒤 응답만
|
|
195
|
+
// 끊겼을 수도 있다. 그 상태에서 다시 올리면 중복 업로드가 되고 한도만
|
|
196
|
+
// 깎인다. 무엇을 모르는지 그대로 알려줘야 사용자가 판단할 수 있다.
|
|
197
|
+
throw new VelogApiError(`이미지 업로드 중 통신이 끊겼습니다: ${this.#mask(reason)}` +
|
|
198
|
+
`${codes.length ? ` (${codes.join('/')})` : ''}\n` +
|
|
199
|
+
'⚠️ 올라갔는지 아닌지 알 수 없습니다. 다시 올리면 같은 그림이 두 번 저장될 수 ' +
|
|
200
|
+
'있으니(삭제 API 없음) 벨로그에서 확인한 뒤 결정하세요.', { networkCodes: codes });
|
|
201
|
+
}
|
|
202
|
+
this.#tokens.update(parseSetCookie(response.headers.get('set-cookie')));
|
|
203
|
+
if (!response.ok) {
|
|
204
|
+
const body = this.#mask(await response.text().catch(() => ''));
|
|
205
|
+
let hint = '';
|
|
206
|
+
if (response.status === 401 || response.status === 403)
|
|
207
|
+
hint = AUTH_HINT;
|
|
208
|
+
if (response.status === 429) {
|
|
209
|
+
hint =
|
|
210
|
+
' — 벨로그가 업로드를 일시 차단했습니다. 1시간 100건 / 1분 20건이 한도입니다.';
|
|
211
|
+
}
|
|
212
|
+
if (response.status === 413)
|
|
213
|
+
hint = ' — 파일이 너무 큽니다 (서버 상한 30MB).';
|
|
214
|
+
// ★ 5xx 는 서버가 이미 저장한 뒤 실패했을 수도 있다. 통신 단절과 같은 성격이다.
|
|
215
|
+
if (response.status >= 500) {
|
|
216
|
+
hint =
|
|
217
|
+
'\n⚠️ 서버 오류라 저장 여부를 알 수 없습니다. 다시 올리면 중복될 수 ' +
|
|
218
|
+
'있으니(삭제 API 없음) 벨로그에서 확인한 뒤 결정하세요.';
|
|
219
|
+
}
|
|
220
|
+
throw new VelogApiError(`이미지 업로드 HTTP ${response.status}: ${truncate(body, 300)}${hint}`, { status: response.status });
|
|
221
|
+
}
|
|
222
|
+
let payload;
|
|
223
|
+
try {
|
|
224
|
+
payload = (await response.json());
|
|
225
|
+
}
|
|
226
|
+
catch (cause) {
|
|
227
|
+
// 2xx 를 받고 본문만 못 읽은 경우다 — 서버는 이미 저장했을 가능성이 높다.
|
|
228
|
+
throw new VelogApiError('업로드 응답을 JSON 으로 읽지 못했습니다: ' +
|
|
229
|
+
this.#mask(cause instanceof Error ? cause.message : String(cause)) +
|
|
230
|
+
'\n⚠️ 서버는 200 을 줬으므로 이미 저장됐을 수 있습니다. 다시 올리기 전에 확인하세요.');
|
|
231
|
+
}
|
|
232
|
+
if (typeof payload.path !== 'string' || payload.path === '') {
|
|
233
|
+
throw new VelogApiError('업로드는 됐는데 서버가 이미지 주소를 주지 않았습니다.');
|
|
234
|
+
}
|
|
235
|
+
return payload.path;
|
|
236
|
+
}
|
|
237
|
+
async #requestOnce(query, variables) {
|
|
238
|
+
const headers = {
|
|
239
|
+
'Content-Type': 'application/json',
|
|
240
|
+
Accept: 'application/json',
|
|
241
|
+
};
|
|
242
|
+
// ★ 자격증명은 벨로그 정규 엔드포인트로만 보낸다.
|
|
243
|
+
// endpoint 는 테스트 주입용 옵션인데, 임의 URL 이 들어오면 쿠키가 그
|
|
244
|
+
// 호스트로 나간다. "벨로그 외 호스트에 접속하지 않는다"는 보장을 코드로
|
|
245
|
+
// 지키려면 여기서 목적지를 확인해야 한다 — 문자열 검색 테스트로는 못 막는다.
|
|
246
|
+
const auth = this.#tokens.state;
|
|
247
|
+
if (auth.kind === 'authenticated') {
|
|
248
|
+
if (this.#endpoint === VELOG_ENDPOINT) {
|
|
249
|
+
headers['Cookie'] = buildCookieHeader(auth.credentials);
|
|
250
|
+
}
|
|
251
|
+
else if (!this.#allowInsecureEndpoint) {
|
|
252
|
+
throw new VelogApiError(`인증 요청은 ${VELOG_ENDPOINT} 로만 보낼 수 있습니다. ` +
|
|
253
|
+
'다른 엔드포인트로는 토큰을 전송하지 않습니다.');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
let response;
|
|
257
|
+
try {
|
|
258
|
+
response = await this.#fetch(this.#endpoint, {
|
|
259
|
+
method: 'POST',
|
|
260
|
+
headers,
|
|
261
|
+
body: JSON.stringify({ query, variables }),
|
|
262
|
+
signal: AbortSignal.timeout(this.#timeoutMs),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
catch (cause) {
|
|
266
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
267
|
+
const codes = collectCauseCodes(cause);
|
|
268
|
+
// 네트워크 예외에도 요청 정보가 실릴 수 있으므로 마스킹한다.
|
|
269
|
+
// 원인 코드를 detail 에 실어야 재시도 판정이 동작한다.
|
|
270
|
+
throw new VelogApiError(`벨로그 요청 실패: ${this.#mask(reason)}${codes.length ? ` (${codes.join('/')})` : ''}`, { networkCodes: codes });
|
|
271
|
+
}
|
|
272
|
+
// ★ 벨로그는 access_token 수명이 30분 아래로 내려가면 refresh_token 으로
|
|
273
|
+
// 재발급해 Set-Cookie 로 돌려준다 (공식 authPlugin.mts). 이걸 버리면
|
|
274
|
+
// 1시간마다 세션이 죽는다. 받아서 메모리에만 반영한다 — 디스크엔 안 쓴다.
|
|
275
|
+
// 실패 응답에도 실려 올 수 있으므로 상태 확인보다 먼저 처리한다.
|
|
276
|
+
this.#tokens.update(parseSetCookie(response.headers.get('set-cookie')));
|
|
277
|
+
if (!response.ok) {
|
|
278
|
+
const body = this.#mask(await response.text().catch(() => ''));
|
|
279
|
+
// ★ 벨로그는 만료 토큰에 HTTP 401 을 준다 (실측). GraphQL errors 경로가
|
|
280
|
+
// 아니라 여기로 떨어지므로, 만료 안내를 이쪽에도 붙여야 한다.
|
|
281
|
+
// 실사용에서 제일 흔한 오류인데 안내가 없으면 원인을 못 찾는다.
|
|
282
|
+
const hint = response.status === 401 || response.status === 403 ? AUTH_HINT : '';
|
|
283
|
+
throw new VelogApiError(`벨로그 HTTP ${response.status}: ${truncate(body, 400)}${hint}`, { status: response.status });
|
|
284
|
+
}
|
|
285
|
+
// ★ JSON 파싱 실패가 그냥 SyntaxError 로 빠지면 마스킹도 재시도 판정도 우회한다.
|
|
286
|
+
// 벨로그가 502 HTML 을 200 으로 주는 경우가 실제로 있다.
|
|
287
|
+
let payload;
|
|
288
|
+
try {
|
|
289
|
+
payload = (await response.json());
|
|
290
|
+
}
|
|
291
|
+
catch (cause) {
|
|
292
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
293
|
+
throw new VelogApiError(`벨로그 응답을 JSON 으로 읽지 못했습니다: ${this.#mask(reason)}`, { networkCodes: collectCauseCodes(cause) });
|
|
294
|
+
}
|
|
295
|
+
if (payload.errors?.length) {
|
|
296
|
+
const messages = payload.errors
|
|
297
|
+
.map((e) => e.message ?? '(메시지 없음)')
|
|
298
|
+
.join(' / ');
|
|
299
|
+
const codes = payload.errors.map((e) => e.extensions?.code).filter(Boolean);
|
|
300
|
+
// 만료를 뭉뚱그리면 사용자가 원인을 못 찾는다. 별도로 짚어준다.
|
|
301
|
+
const looksUnauthenticated = codes.includes('UNAUTHENTICATED') || /not logged|unauthor/i.test(messages);
|
|
302
|
+
const hint = looksUnauthenticated ? AUTH_HINT : '';
|
|
303
|
+
// ★ 원본 payload.errors 를 그대로 담으면 토큰이 Error 객체에 남는다.
|
|
304
|
+
// 지금은 MCP SDK 가 message 만 내보내지만, 나중에 console.error(error)
|
|
305
|
+
// 나 오류 수집기를 붙이는 순간 샌다. 코드만 보관한다.
|
|
306
|
+
throw new VelogApiError(`벨로그 GraphQL 오류: ${this.#mask(messages)}${hint}`, {
|
|
307
|
+
graphqlErrorCodes: codes.filter((c) => typeof c === 'string'),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
if (payload.data === undefined || payload.data === null) {
|
|
311
|
+
throw new VelogApiError('벨로그가 빈 응답을 반환했습니다.');
|
|
312
|
+
}
|
|
313
|
+
return payload.data;
|
|
314
|
+
}
|
|
315
|
+
#mask(text) {
|
|
316
|
+
return this.#tokens.mask(text);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function truncate(text, max) {
|
|
320
|
+
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
321
|
+
}
|
|
322
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 응답 정형화.
|
|
3
|
+
*
|
|
4
|
+
* MCP 도구는 결국 모델이 읽는다. 원본 JSON 을 그대로 던지면 토큰만 먹고
|
|
5
|
+
* 정작 필요한 값(제목·슬러그·조회수)은 묻힌다. 목록은 표로, 단건은 본문 위주로 낸다.
|
|
6
|
+
*/
|
|
7
|
+
import type { VelogPostSummary, VelogPostDetail } from './types.ts';
|
|
8
|
+
export declare function postUrl(post: VelogPostSummary): string;
|
|
9
|
+
/** title 도 nullable 이라 표시 전에 한 번 거른다. */
|
|
10
|
+
export declare function postTitle(post: VelogPostSummary): string;
|
|
11
|
+
/** 목록 — 한 줄에 하나. 모델이 훑고 고르기 좋게. */
|
|
12
|
+
export declare function formatPostList(posts: readonly VelogPostSummary[], options?: {
|
|
13
|
+
readonly total?: number;
|
|
14
|
+
readonly showAuthor?: boolean;
|
|
15
|
+
}): string;
|
|
16
|
+
/** 단건 — 메타는 압축하고 본문을 그대로 넘긴다. */
|
|
17
|
+
export declare function formatPostDetail(post: VelogPostDetail): string;
|
|
18
|
+
/** MCP 도구 반환 형태. */
|
|
19
|
+
export declare function textResult(text: string): {
|
|
20
|
+
content: {
|
|
21
|
+
type: "text";
|
|
22
|
+
text: string;
|
|
23
|
+
}[];
|
|
24
|
+
};
|
|
25
|
+
export declare function errorResult(text: string): {
|
|
26
|
+
content: {
|
|
27
|
+
type: "text";
|
|
28
|
+
text: string;
|
|
29
|
+
}[];
|
|
30
|
+
isError: true;
|
|
31
|
+
};
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 응답 정형화.
|
|
3
|
+
*
|
|
4
|
+
* MCP 도구는 결국 모델이 읽는다. 원본 JSON 을 그대로 던지면 토큰만 먹고
|
|
5
|
+
* 정작 필요한 값(제목·슬러그·조회수)은 묻힌다. 목록은 표로, 단건은 본문 위주로 낸다.
|
|
6
|
+
*/
|
|
7
|
+
export function postUrl(post) {
|
|
8
|
+
const username = post.user?.username;
|
|
9
|
+
// url_slug 는 공식 스키마상 nullable 이다.
|
|
10
|
+
const slug = post.url_slug;
|
|
11
|
+
if (!slug)
|
|
12
|
+
return `(id: ${post.id})`;
|
|
13
|
+
return username ? `https://velog.io/@${username}/${slug}` : `(url_slug: ${slug})`;
|
|
14
|
+
}
|
|
15
|
+
/** title 도 nullable 이라 표시 전에 한 번 거른다. */
|
|
16
|
+
export function postTitle(post) {
|
|
17
|
+
return post.title?.trim() || '(제목 없음)';
|
|
18
|
+
}
|
|
19
|
+
function dateOnly(iso) {
|
|
20
|
+
return iso ? iso.slice(0, 10) : '—';
|
|
21
|
+
}
|
|
22
|
+
/** 목록 — 한 줄에 하나. 모델이 훑고 고르기 좋게. */
|
|
23
|
+
export function formatPostList(posts, options = {}) {
|
|
24
|
+
if (posts.length === 0)
|
|
25
|
+
return '결과 없음';
|
|
26
|
+
const lines = posts.map((p, i) => {
|
|
27
|
+
const head = `${String(i + 1).padStart(2)}. ${postTitle(p)}`;
|
|
28
|
+
const author = options.showAuthor && p.user?.username ? `@${p.user.username} · ` : '';
|
|
29
|
+
const stats = `${author}♥${p.likes ?? 0} · 👁${p.views ?? 0} · 💬${p.comments_count ?? 0}`;
|
|
30
|
+
const tags = p.tags?.length ? ` · #${p.tags.join(' #')}` : '';
|
|
31
|
+
const priv = p.is_private ? ' · 🔒비공개' : '';
|
|
32
|
+
return `${head}\n ${dateOnly(p.released_at)} · ${stats}${tags}${priv}\n ${postUrl(p)}`;
|
|
33
|
+
});
|
|
34
|
+
const header = options.total !== undefined && options.total > posts.length
|
|
35
|
+
? `총 ${options.total.toLocaleString('ko-KR')}건 중 ${posts.length}건\n\n`
|
|
36
|
+
: `${posts.length}건\n\n`;
|
|
37
|
+
return header + lines.join('\n\n');
|
|
38
|
+
}
|
|
39
|
+
/** 단건 — 메타는 압축하고 본문을 그대로 넘긴다. */
|
|
40
|
+
export function formatPostDetail(post) {
|
|
41
|
+
const meta = [
|
|
42
|
+
`# ${postTitle(post)}`,
|
|
43
|
+
'',
|
|
44
|
+
`- URL: ${postUrl(post)}`,
|
|
45
|
+
`- 작성: ${dateOnly(post.released_at ?? post.created_at)}`,
|
|
46
|
+
`- 통계: ♥${post.likes ?? 0} · 👁${post.views ?? 0} · 💬${post.comments_count ?? 0}`,
|
|
47
|
+
];
|
|
48
|
+
if (post.tags?.length)
|
|
49
|
+
meta.push(`- 태그: ${post.tags.map((t) => `#${t}`).join(' ')}`);
|
|
50
|
+
if (post.series?.name)
|
|
51
|
+
meta.push(`- 시리즈: ${post.series.name}`);
|
|
52
|
+
if (post.is_temp)
|
|
53
|
+
meta.push('- 상태: **임시저장(비공개)**');
|
|
54
|
+
else if (post.is_private)
|
|
55
|
+
meta.push('- 상태: 비공개');
|
|
56
|
+
meta.push(`- id: \`${post.id}\``);
|
|
57
|
+
return `${meta.join('\n')}\n\n---\n\n${post.body ?? '(본문 없음)'}`;
|
|
58
|
+
}
|
|
59
|
+
/** MCP 도구 반환 형태. */
|
|
60
|
+
export function textResult(text) {
|
|
61
|
+
return { content: [{ type: 'text', text }] };
|
|
62
|
+
}
|
|
63
|
+
export function errorResult(text) {
|
|
64
|
+
return { content: [{ type: 'text', text }], isError: true };
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=format.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"format.js","sourceRoot":"","sources":["../src/format.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphQL 질의문 모음.
|
|
3
|
+
*
|
|
4
|
+
* 필드 선택은 docs/api-reference.md 의 실측 스키마를 따른다.
|
|
5
|
+
*
|
|
6
|
+
* ★ 한 요청에 쿼리를 여러 개 묶지 않는다.
|
|
7
|
+
* 벨로그는 Prisma 커넥션 풀이 작아(limit 5 / timeout 10s) 묶어 보내면
|
|
8
|
+
* "Timed out fetching a new connection from the connection pool" 이 뜬다.
|
|
9
|
+
* 2026-07-30 실측: searchPosts + trendingPosts 동시 요청 시 재현됨.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* 목록에 쓰는 최소 필드. body 를 빼서 응답 크기를 줄인다.
|
|
13
|
+
*
|
|
14
|
+
* ★ `updated_at` 을 일부러 뺐다.
|
|
15
|
+
* 스키마상 non-nullable 인데 실제로는 null 인 글이 존재한다. 그런 글이 결과에
|
|
16
|
+
* 하나라도 섞이면 GraphQL 이 응답 전체를 거부한다:
|
|
17
|
+
* "Cannot return null for non-nullable field Post.updated_at."
|
|
18
|
+
* 2026-07-30 실측 — searchPosts(keyword:"MCP") 에서 재현. `released_at` 만
|
|
19
|
+
* 남기면 정상. 필드 하나를 잃는 것보다 글을 통째로 못 읽는 쪽이 나쁘다.
|
|
20
|
+
*/
|
|
21
|
+
export declare const POST_SUMMARY_FIELDS = "\n id\n title\n url_slug\n short_description\n thumbnail\n likes\n views\n comments_count\n released_at\n is_private\n tags\n user { username profile { display_name thumbnail } }\n series { id name url_slug }\n";
|
|
22
|
+
/** 단건 조회 — 본문 포함. */
|
|
23
|
+
export declare const POST_DETAIL_FIELDS = "\n \n id\n title\n url_slug\n short_description\n thumbnail\n likes\n views\n comments_count\n released_at\n is_private\n tags\n user { username profile { display_name thumbnail } }\n series { id name url_slug }\n\n body\n is_markdown\n is_temp\n created_at\n";
|
|
24
|
+
export declare const QUERY_POST = "\n query GetPost($input: ReadPostInput!) {\n post(input: $input) { \n \n id\n title\n url_slug\n short_description\n thumbnail\n likes\n views\n comments_count\n released_at\n is_private\n tags\n user { username profile { display_name thumbnail } }\n series { id name url_slug }\n\n body\n is_markdown\n is_temp\n created_at\n }\n }\n";
|
|
25
|
+
export declare const QUERY_POSTS = "\n query GetPosts($input: GetPostsInput!) {\n posts(input: $input) { \n id\n title\n url_slug\n short_description\n thumbnail\n likes\n views\n comments_count\n released_at\n is_private\n tags\n user { username profile { display_name thumbnail } }\n series { id name url_slug }\n }\n }\n";
|
|
26
|
+
export declare const QUERY_SEARCH_POSTS = "\n query SearchPosts($input: GetSearchPostsInput!) {\n searchPosts(input: $input) {\n count\n posts { \n id\n title\n url_slug\n short_description\n thumbnail\n likes\n views\n comments_count\n released_at\n is_private\n tags\n user { username profile { display_name thumbnail } }\n series { id name url_slug }\n }\n }\n }\n";
|
|
27
|
+
export declare const QUERY_TRENDING_POSTS = "\n query TrendingPosts($input: TrendingPostsInput!) {\n trendingPosts(input: $input) { \n id\n title\n url_slug\n short_description\n thumbnail\n likes\n views\n comments_count\n released_at\n is_private\n tags\n user { username profile { display_name thumbnail } }\n series { id name url_slug }\n }\n }\n";
|
|
28
|
+
export declare const QUERY_RECENT_POSTS = "\n query RecentPosts($input: RecentPostsInput!) {\n recentPosts(input: $input) { \n id\n title\n url_slug\n short_description\n thumbnail\n likes\n views\n comments_count\n released_at\n is_private\n tags\n user { username profile { display_name thumbnail } }\n series { id name url_slug }\n }\n }\n";
|
|
29
|
+
export declare const QUERY_CURRENT_USER = "\n query CurrentUser {\n currentUser {\n id\n username\n email\n profile { display_name short_bio thumbnail }\n }\n }\n";
|