@zhin.js/adapter-juejin 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +145 -2
  2. package/adapters/juejin/index.js +26 -0
  3. package/adapters/juejin/index.ts +29 -0
  4. package/commands/juejin-draft/drop/[id]/index.js +13 -0
  5. package/commands/juejin-draft/drop/[id]/index.ts +13 -0
  6. package/commands/juejin-draft/edit/[id]/index.js +16 -0
  7. package/commands/juejin-draft/edit/[id]/index.ts +15 -0
  8. package/commands/juejin-draft/list/index.js +12 -0
  9. package/commands/juejin-draft/list/index.ts +12 -0
  10. package/commands/juejin-draft/send/[id]/index.js +13 -0
  11. package/commands/juejin-draft/send/[id]/index.ts +13 -0
  12. package/commands/juejin-draft/show/[id]/index.js +13 -0
  13. package/commands/juejin-draft/show/[id]/index.ts +13 -0
  14. package/commands/juejin-login/status/index.js +12 -0
  15. package/commands/juejin-login/status/index.ts +12 -0
  16. package/lib/adapter.d.ts +22 -0
  17. package/lib/adapter.js +43 -0
  18. package/lib/client.d.ts +83 -0
  19. package/lib/client.js +192 -0
  20. package/lib/console.d.ts +16 -0
  21. package/lib/console.js +172 -0
  22. package/lib/drafts.d.ts +50 -0
  23. package/lib/drafts.js +137 -0
  24. package/lib/endpoint-directory.d.ts +16 -0
  25. package/lib/endpoint-directory.js +63 -0
  26. package/lib/endpoint.d.ts +86 -0
  27. package/lib/endpoint.js +440 -0
  28. package/lib/index.d.ts +11 -0
  29. package/lib/index.js +11 -0
  30. package/lib/juejin-runtime-state.d.ts +4 -0
  31. package/lib/juejin-runtime-state.js +3 -0
  32. package/lib/login-text.d.ts +5 -0
  33. package/lib/login-text.js +25 -0
  34. package/lib/protocol.d.ts +192 -0
  35. package/lib/protocol.js +214 -0
  36. package/lib/review.d.ts +29 -0
  37. package/lib/review.js +156 -0
  38. package/lib/self-profile.d.ts +9 -0
  39. package/lib/self-profile.js +14 -0
  40. package/lib/storage.d.ts +11 -0
  41. package/lib/storage.js +54 -0
  42. package/package.json +113 -3
  43. package/pages/index/CommentReview.js +140 -0
  44. package/pages/index/CommentReview.tsx +264 -0
  45. package/pages/index/index.js +18 -0
  46. package/pages/index/index.tsx +18 -0
  47. package/pages/index/messages.js +57 -0
  48. package/pages/index/messages.ts +59 -0
  49. package/pages/index/rpc.js +31 -0
  50. package/pages/index/rpc.ts +72 -0
  51. package/plugin.js +33 -0
  52. package/schema.json +485 -0
  53. package/src/adapter.ts +45 -0
  54. package/src/client.ts +247 -0
  55. package/src/console.ts +161 -0
  56. package/src/drafts.ts +180 -0
  57. package/src/endpoint-directory.ts +64 -0
  58. package/src/endpoint.ts +504 -0
  59. package/src/index.ts +50 -0
  60. package/src/juejin-runtime-state.ts +11 -0
  61. package/src/login-text.ts +28 -0
  62. package/src/protocol.ts +377 -0
  63. package/src/review.ts +149 -0
  64. package/src/self-profile.ts +20 -0
  65. package/src/storage.ts +52 -0
package/lib/client.js ADDED
@@ -0,0 +1,192 @@
1
+ /**
2
+ * 掘金 Web API client(非官方接口,Experimental)。
3
+ * 读:白名单文章的最新评论(`interact_api/v1/comment/list`)、登录账号(`user_api/v1/user/get`);
4
+ * 写:`replyComment`(`interact_api/v1/reply/publish`),只在人工审批草稿后由 endpoint 调用。
5
+ * 永远不要记录 Cookie。
6
+ */
7
+ import { JUEJIN_ARTICLE_ITEM_TYPE, JUEJIN_CLIENT_TYPE, JUEJIN_COMMENT_PAGE_SIZE, JUEJIN_LOGIN_REQUIRED_CODE, JUEJIN_NON_JSON_CODE, JUEJIN_RISK_HTTP_STATUSES, JUEJIN_RISK_MESSAGE, isJuejinId, } from './protocol.js';
8
+ const REQUEST_TIMEOUT_MS = 15_000;
9
+ const USER_AGENT = 'Mozilla/5.0 (zhin.js adapter-juejin; experimental)';
10
+ const WEB_ORIGIN = 'https://juejin.cn';
11
+ /**
12
+ * 平台返回非 0 `err_no`、非 2xx 或非 JSON。`risk` 为 true 时调用方必须熔断;
13
+ * `ambiguous` 为 true 表示写请求结果未知(可能已经发出),不能当成「没发」重试。
14
+ */
15
+ export class JuejinApiError extends Error {
16
+ code;
17
+ risk;
18
+ ambiguous;
19
+ constructor(code, message, options = {}) {
20
+ super(message);
21
+ this.code = code;
22
+ this.name = 'JuejinApiError';
23
+ this.httpStatus = options.httpStatus;
24
+ this.ambiguous = options.ambiguous === true;
25
+ this.risk = (options.httpStatus !== undefined && JUEJIN_RISK_HTTP_STATUSES.has(options.httpStatus))
26
+ || code === JUEJIN_NON_JSON_CODE || JUEJIN_RISK_MESSAGE.test(message);
27
+ }
28
+ httpStatus;
29
+ /** 平台判定未登录 / Cookie 失效。 */
30
+ get loginRequired() {
31
+ return this.code === JUEJIN_LOGIN_REQUIRED_CODE && this.httpStatus === undefined;
32
+ }
33
+ }
34
+ export class JuejinClient {
35
+ config;
36
+ #cookie;
37
+ fetch;
38
+ constructor(config, fetch, signal) {
39
+ this.config = config;
40
+ this.fetch = bindJuejinFetch(fetch, signal);
41
+ this.#cookie = config.cookie;
42
+ }
43
+ get hasCookie() {
44
+ return Boolean(this.#cookie);
45
+ }
46
+ /** 切换 Cookie(传 undefined 清空)。 */
47
+ setCookie(cookie) {
48
+ this.#cookie = cookie || undefined;
49
+ }
50
+ #headers(json) {
51
+ return {
52
+ 'User-Agent': USER_AGENT,
53
+ Accept: 'application/json, text/plain, */*',
54
+ Referer: `${WEB_ORIGIN}/`,
55
+ Origin: WEB_ORIGIN,
56
+ ...(this.#cookie ? { Cookie: this.#cookie } : {}),
57
+ ...(json ? { 'Content-Type': 'application/json' } : {}),
58
+ };
59
+ }
60
+ /**
61
+ * 发请求并解出 `{ err_no, err_msg, data }` 信封。`write` 为 true 时,拿不到可解析的结果
62
+ * (网络中断、超时、空响应、非 JSON)都按「结果未知」抛出 `ambiguous` 错误。
63
+ */
64
+ async request(path, init = {}) {
65
+ const separator = path.includes('?') ? '&' : '?';
66
+ const url = `${this.config.apiBaseUrl}${path}${separator}aid=${JUEJIN_CLIENT_TYPE}`;
67
+ const write = init.write === true;
68
+ let response;
69
+ try {
70
+ response = await this.fetch(url, {
71
+ method: init.method ?? 'GET',
72
+ headers: this.#headers(init.body !== undefined),
73
+ ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
74
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
75
+ });
76
+ }
77
+ catch (error) {
78
+ if (!write)
79
+ throw error;
80
+ throw new JuejinApiError(-1, `Juejin request outcome unknown: ${error instanceof Error ? error.message : String(error)}`, { ambiguous: true });
81
+ }
82
+ if (!response.ok) {
83
+ throw new JuejinApiError(-response.status, `Juejin HTTP ${response.status}`, { httpStatus: response.status });
84
+ }
85
+ const raw = await response.text().catch((error) => {
86
+ if (write)
87
+ throw new JuejinApiError(-1, 'Juejin response body was lost', { ambiguous: true });
88
+ throw error;
89
+ });
90
+ if (!raw.trim()) {
91
+ throw new JuejinApiError(-1, 'Juejin returned an empty response', { ambiguous: write });
92
+ }
93
+ let envelope;
94
+ try {
95
+ envelope = JSON.parse(raw);
96
+ }
97
+ catch {
98
+ // 非 JSON:多半被拦到验证页 / 登录页。写请求结果未知;同时当成风控熔断。
99
+ throw new JuejinApiError(JUEJIN_NON_JSON_CODE, 'Juejin returned a non-JSON response (possible verification page)', { ambiguous: write });
100
+ }
101
+ if (typeof envelope?.err_no !== 'number')
102
+ throw new JuejinApiError(-1, 'Malformed Juejin response', { ambiguous: write });
103
+ if (envelope.err_no !== 0) {
104
+ throw new JuejinApiError(envelope.err_no, `Juejin API ${envelope.err_no}: ${envelope.err_msg ?? ''}`.trim());
105
+ }
106
+ return envelope;
107
+ }
108
+ /** 登录态自检:当前账号(未登录 / Cookie 失效返回 undefined)。掘金未登录时返回 `data: null` 或 `err_no: 403`。 */
109
+ async getSelf() {
110
+ if (!this.hasCookie)
111
+ return undefined;
112
+ let envelope;
113
+ try {
114
+ envelope = await this.request('/user_api/v1/user/get');
115
+ }
116
+ catch (error) {
117
+ if (error instanceof JuejinApiError && error.loginRequired)
118
+ return undefined;
119
+ throw error;
120
+ }
121
+ const data = envelope.data;
122
+ if (!data || !isJuejinId(data.user_id))
123
+ return undefined;
124
+ return Object.freeze({
125
+ userId: data.user_id,
126
+ ...(typeof data.user_name === 'string' && data.user_name ? { userName: data.user_name } : {}),
127
+ ...(typeof data.avatar_large === 'string' && data.avatar_large ? { avatar: data.avatar_large } : {}),
128
+ });
129
+ }
130
+ /** 读取文章评论第一页(`sort=0` 最新在前),一级评论带内联的前几条回复。公开接口,不需要 Cookie。 */
131
+ async listLatestComments(articleId, limit = JUEJIN_COMMENT_PAGE_SIZE) {
132
+ const envelope = await this.request('/interact_api/v1/comment/list', {
133
+ method: 'POST',
134
+ body: {
135
+ item_id: articleId,
136
+ item_type: JUEJIN_ARTICLE_ITEM_TYPE,
137
+ cursor: '0',
138
+ limit,
139
+ sort: 0,
140
+ client_type: JUEJIN_CLIENT_TYPE,
141
+ },
142
+ });
143
+ return Object.freeze({
144
+ comments: Object.freeze([...(Array.isArray(envelope.data) ? envelope.data : [])]),
145
+ ...(typeof envelope.count === 'number' ? { count: envelope.count } : {}),
146
+ ...(typeof envelope.has_more === 'boolean' ? { hasMore: envelope.has_more } : {}),
147
+ });
148
+ }
149
+ /**
150
+ * 回复评论(`POST interact_api/v1/reply/publish`)。只回复已有评论楼:`reply_to_comment_id` = 楼根,
151
+ * 回复楼中楼时再带 `reply_to_reply_id`。字段取自社区实现(androidZzT/juejin-mcp `browser/comment.go`);
152
+ * 纯 HTTP 调用可能被掘金的反爬签名拒绝,见 docs/adapters/juejin.md「手动验证清单」。
153
+ *
154
+ * 只由 `JuejinEndpoint.approveDraft()` 在人工审批后调用;`send()` 永远不直接调用。
155
+ * 返回新回复 id(平台没给时为 undefined,但 `err_no: 0` 仍视为已发出)。
156
+ */
157
+ async replyComment(input) {
158
+ if (!this.#cookie) {
159
+ throw new JuejinApiError(JUEJIN_LOGIN_REQUIRED_CODE, 'Juejin cookie is required to reply');
160
+ }
161
+ const envelope = await this.request('/interact_api/v1/reply/publish', {
162
+ method: 'POST',
163
+ write: true,
164
+ body: {
165
+ client_type: JUEJIN_CLIENT_TYPE,
166
+ item_id: input.articleId,
167
+ item_type: JUEJIN_ARTICLE_ITEM_TYPE,
168
+ reply_content: input.message,
169
+ reply_pics: [],
170
+ reply_to_comment_id: input.root,
171
+ ...(input.parent !== input.root ? { reply_to_reply_id: input.parent } : {}),
172
+ },
173
+ });
174
+ const id = envelope.data?.reply_id ?? envelope.data?.reply_info?.reply_id;
175
+ return isJuejinId(id) ? id : undefined;
176
+ }
177
+ }
178
+ /** Bind the actual account incarnation without changing existing request timeouts. */
179
+ function bindJuejinFetch(fetch, signal) {
180
+ if (!signal)
181
+ return fetch;
182
+ return async (url, init) => {
183
+ signal.throwIfAborted();
184
+ const response = await fetch(url, { ...init, signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal });
185
+ signal.throwIfAborted();
186
+ return { ok: response.ok, status: response.status, async text() {
187
+ const body = await response.text();
188
+ signal.throwIfAborted();
189
+ return body;
190
+ } };
191
+ };
192
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Console「掘金评论审批」页的插件 RPC(`plugin.<pluginId>.status` / 动作)。
3
+ *
4
+ * - 只返回页面需要的字段:登录态(不含 Cookie)、白名单文章、待审草稿。
5
+ * - 动作都声明了 requiredPermissions:普通用户令牌只有持有对应权限才能操作(未声明 = 仅管理员)。
6
+ * - 配置(白名单、sendMode、Cookie 等)不在这里改:用 YAML / 环境变量 / config:patch。
7
+ */
8
+ import { type PluginConsoleRpc } from 'zhin.js';
9
+ export declare const JUEJIN_CONSOLE_PAGE = "index";
10
+ /** 看页面与状态。 */
11
+ export declare const JUEJIN_READ_PERMISSION = "juejin:read";
12
+ /** 改写 / 丢弃 / 发送草稿。 */
13
+ export declare const JUEJIN_REVIEW_PERMISSION = "juejin:review";
14
+ /** 重新检查登录态。 */
15
+ export declare const JUEJIN_LOGIN_PERMISSION = "juejin:login";
16
+ export declare function createJuejinConsoleRpc(): PluginConsoleRpc;
package/lib/console.js ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Console「掘金评论审批」页的插件 RPC(`plugin.<pluginId>.status` / 动作)。
3
+ *
4
+ * - 只返回页面需要的字段:登录态(不含 Cookie)、白名单文章、待审草稿。
5
+ * - 动作都声明了 requiredPermissions:普通用户令牌只有持有对应权限才能操作(未声明 = 仅管理员)。
6
+ * - 配置(白名单、sendMode、Cookie 等)不在这里改:用 YAML / 环境变量 / config:patch。
7
+ */
8
+ import { definePluginConsoleRpc, PluginConsoleRpcError } from 'zhin.js';
9
+ import { isLocale } from 'zhin.js/i18n';
10
+ import { juejinEndpoints } from './endpoint-directory.js';
11
+ import { JuejinReviewError } from './endpoint.js';
12
+ import { draftArticleUrl, findDraft, reviewErrorText } from './review.js';
13
+ export const JUEJIN_CONSOLE_PAGE = 'index';
14
+ /** 看页面与状态。 */
15
+ export const JUEJIN_READ_PERMISSION = 'juejin:read';
16
+ /** 改写 / 丢弃 / 发送草稿。 */
17
+ export const JUEJIN_REVIEW_PERMISSION = 'juejin:review';
18
+ /** 重新检查登录态。 */
19
+ export const JUEJIN_LOGIN_PERMISSION = 'juejin:login';
20
+ const DRAFTS_PER_ENDPOINT = 50;
21
+ function localeOf(input) {
22
+ return typeof input.locale === 'string' && isLocale(input.locale) ? input.locale : undefined;
23
+ }
24
+ function requireString(input, key, label) {
25
+ const value = input[key];
26
+ if (typeof value !== 'string' || !value.trim())
27
+ throw new PluginConsoleRpcError('invalid_input', `缺少${label}`);
28
+ return value.trim();
29
+ }
30
+ function pickEndpoint(registry, input) {
31
+ const id = typeof input.endpoint === 'string' ? input.endpoint.trim() : '';
32
+ if (id) {
33
+ const endpoint = registry.get(id);
34
+ if (!endpoint)
35
+ throw new PluginConsoleRpcError('not_found', `找不到账号 ${id}`);
36
+ return endpoint;
37
+ }
38
+ const [first, second] = registry.values();
39
+ if (!first)
40
+ throw new PluginConsoleRpcError('unavailable', '掘金适配器还没有启动');
41
+ if (second)
42
+ throw new PluginConsoleRpcError('invalid_input', '有多个账号,请先选择一个');
43
+ return first;
44
+ }
45
+ function reviewFailure(id, error, locale) {
46
+ if (error instanceof PluginConsoleRpcError)
47
+ throw error;
48
+ const message = reviewErrorText(id, error, locale);
49
+ if (error instanceof JuejinReviewError) {
50
+ const code = error.code === 'not_found' ? 'not_found'
51
+ : error.code === 'busy' || error.code === 'rate_limited' || error.code === 'unknown_outcome' ? 'conflict'
52
+ : error.code === 'invalid_message' ? 'invalid_input' : 'unavailable';
53
+ throw new PluginConsoleRpcError(code, message);
54
+ }
55
+ throw new PluginConsoleRpcError('unavailable', message);
56
+ }
57
+ function endpointView(endpoint) {
58
+ const login = endpoint.login;
59
+ const circuit = endpoint.circuitBreaker;
60
+ const drafts = endpoint.drafts.list().slice(-DRAFTS_PER_ENDPOINT).reverse();
61
+ return {
62
+ id: endpoint.localId,
63
+ sendMode: endpoint.sendMode,
64
+ persistent: endpoint.persistent,
65
+ circuit: circuit ? { code: circuit.code } : null,
66
+ login: {
67
+ state: login.state,
68
+ ...(login.userId ? { userId: login.userId } : {}),
69
+ ...(login.userName ? { userName: login.userName } : {}),
70
+ },
71
+ articles: endpoint.articles.map(article => ({ id: article.id, url: article.url })),
72
+ pendingCount: endpoint.drafts.size,
73
+ drafts: drafts.map(draft => ({
74
+ id: draft.id,
75
+ url: draftArticleUrl(draft),
76
+ articleId: draft.articleId,
77
+ message: draft.message,
78
+ status: draft.status ?? 'pending',
79
+ interrupted: draft.interrupted === true,
80
+ createdAt: draft.createdAt,
81
+ ...(draft.updatedAt ? { updatedAt: draft.updatedAt } : {}),
82
+ ...(draft.context ? { author: draft.context.author, original: draft.context.text } : {}),
83
+ })),
84
+ };
85
+ }
86
+ function scopedRegistry(operation) {
87
+ return (input, call) => {
88
+ const runtime = call.runtime;
89
+ if (!runtime)
90
+ throw new PluginConsoleRpcError('unsupported', 'Juejin Adapter directory is unavailable');
91
+ const directory = juejinEndpoints({ owner: runtime.owner, signal: call.signal, project: feature => runtime.project(feature) });
92
+ return operation(directory, input);
93
+ };
94
+ }
95
+ export function createJuejinConsoleRpc() {
96
+ const page = JUEJIN_CONSOLE_PAGE;
97
+ const review = [JUEJIN_REVIEW_PERMISSION];
98
+ return definePluginConsoleRpc({
99
+ status: {
100
+ page,
101
+ requiredPermissions: [JUEJIN_READ_PERMISSION],
102
+ handler: scopedRegistry((registry) => ({
103
+ endpoints: [...registry.values()].map(endpointView),
104
+ updatedAt: Date.now(),
105
+ })),
106
+ },
107
+ actions: {
108
+ send: {
109
+ page, requiredPermissions: review,
110
+ handler: scopedRegistry(async (registry, input) => {
111
+ const id = requireString(input, 'id', '草稿 ID');
112
+ const found = findDraft(registry, id);
113
+ if (!found)
114
+ throw new PluginConsoleRpcError('not_found', reviewErrorText(id, new JuejinReviewError('not_found', id), localeOf(input)));
115
+ try {
116
+ const { draft, replyId } = await found.endpoint.approveDraft(id);
117
+ return { id, url: draftArticleUrl(draft), ...(replyId ? { replyId } : {}) };
118
+ }
119
+ catch (error) {
120
+ return reviewFailure(id, error, localeOf(input));
121
+ }
122
+ }),
123
+ },
124
+ edit: {
125
+ page, requiredPermissions: review,
126
+ handler: scopedRegistry(async (registry, input) => {
127
+ const id = requireString(input, 'id', '草稿 ID');
128
+ const message = typeof input.message === 'string' ? input.message : '';
129
+ const found = findDraft(registry, id);
130
+ if (!found)
131
+ throw new PluginConsoleRpcError('not_found', reviewErrorText(id, new JuejinReviewError('not_found', id), localeOf(input)));
132
+ try {
133
+ const draft = found.endpoint.editDraft(id, message);
134
+ return { id, message: draft.message };
135
+ }
136
+ catch (error) {
137
+ return reviewFailure(id, error, localeOf(input));
138
+ }
139
+ }),
140
+ },
141
+ drop: {
142
+ page, requiredPermissions: review,
143
+ handler: scopedRegistry(async (registry, input) => {
144
+ const id = requireString(input, 'id', '草稿 ID');
145
+ const found = findDraft(registry, id);
146
+ if (!found)
147
+ throw new PluginConsoleRpcError('not_found', reviewErrorText(id, new JuejinReviewError('not_found', id), localeOf(input)));
148
+ try {
149
+ return { id, dropped: found.endpoint.discardDraft(id) };
150
+ }
151
+ catch (error) {
152
+ return reviewFailure(id, error, localeOf(input));
153
+ }
154
+ }),
155
+ },
156
+ 'login-check': {
157
+ page, requiredPermissions: [JUEJIN_LOGIN_PERMISSION],
158
+ handler: scopedRegistry(async (registry, input) => {
159
+ try {
160
+ const login = await pickEndpoint(registry, input).recheckLogin();
161
+ return { state: login.state, ...(login.userId ? { userId: login.userId } : {}), ...(login.userName ? { userName: login.userName } : {}) };
162
+ }
163
+ catch (error) {
164
+ if (error instanceof PluginConsoleRpcError)
165
+ throw error;
166
+ throw new PluginConsoleRpcError('unavailable', '检查登录失败,请稍后再试');
167
+ }
168
+ }),
169
+ },
170
+ },
171
+ });
172
+ }
@@ -0,0 +1,50 @@
1
+ export type JuejinDraftStatus = 'pending' | 'sending';
2
+ export interface JuejinReplyDraft {
3
+ readonly id: string;
4
+ /** 本地 endpoint id(配置里的 endpoints[i].id)。 */
5
+ readonly endpointId?: string;
6
+ /** 评论楼会话 id:`article:<articleId>:<rootCommentId>`。 */
7
+ readonly target?: string;
8
+ readonly articleId: string;
9
+ /** 评论楼根(一级评论 id)。 */
10
+ readonly root: string;
11
+ /** 直接回复的对象(等于 root = 回复一级评论)。 */
12
+ readonly parent: string;
13
+ readonly message: string;
14
+ /** 被回复评论的摘要(作者 + 原文),供审批时对照。 */
15
+ readonly context?: {
16
+ readonly author: string;
17
+ readonly text: string;
18
+ };
19
+ readonly status?: JuejinDraftStatus;
20
+ /** 上次发送时进程退出或结果未知;再次发送前应先到掘金确认。 */
21
+ readonly interrupted?: boolean;
22
+ readonly createdAt: number;
23
+ readonly updatedAt?: number;
24
+ }
25
+ export type JuejinDraftInput = Omit<JuejinReplyDraft, 'id' | 'createdAt' | 'status' | 'updatedAt' | 'interrupted'>;
26
+ export interface JuejinDraftQueueOptions {
27
+ readonly capacity?: number;
28
+ /** 持久化文件;省略则只存内存。 */
29
+ readonly file?: string;
30
+ /** 持久化失败时回调(不抛出,草稿仍保留在内存)。 */
31
+ readonly onPersistError?: (error: unknown) => void;
32
+ }
33
+ export declare class JuejinDraftQueue {
34
+ #private;
35
+ readonly capacity: number;
36
+ constructor(options?: number | JuejinDraftQueueOptions);
37
+ /** 是否落盘。 */
38
+ get persistent(): boolean;
39
+ add(input: JuejinDraftInput, now?: number): JuejinReplyDraft;
40
+ list(): readonly JuejinReplyDraft[];
41
+ get(id: string): JuejinReplyDraft | undefined;
42
+ /**
43
+ * 原地替换字段(id / createdAt 不变)。草稿不存在时返回 undefined。
44
+ * `interrupted: true` 用于「发送结果未知」;人工改写正文会清掉这个标记。
45
+ */
46
+ update(id: string, patch: Partial<Pick<JuejinReplyDraft, 'message' | 'status' | 'interrupted'>>, now?: number): JuejinReplyDraft | undefined;
47
+ discard(id: string): boolean;
48
+ clear(): void;
49
+ get size(): number;
50
+ }
package/lib/drafts.js ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * 草稿队列(review 模式):AI 生成的回复先停在这里,等人确认后再发。
3
+ *
4
+ * 传入 `file` 时持久化到本地 JSON(默认 `data/juejin/<endpoint>/drafts.json`,权限 0600),进程重启后自动恢复。
5
+ * 重启时仍处于 `sending` 的草稿结果未知(可能已经发出),恢复为 `pending` 并标记 `interrupted`,
6
+ * 提醒审批人先去掘金确认,避免重复发送。
7
+ */
8
+ import { isJuejinId } from './protocol.js';
9
+ import { readJson, removeFile, writeJsonAtomic } from './storage.js';
10
+ function isDraft(value) {
11
+ if (!value || typeof value !== 'object')
12
+ return false;
13
+ const draft = value;
14
+ return typeof draft.id === 'string' && /^[a-z0-9]+$/u.test(draft.id)
15
+ && isJuejinId(draft.articleId) && isJuejinId(draft.root) && isJuejinId(draft.parent)
16
+ && typeof draft.message === 'string'
17
+ && typeof draft.createdAt === 'number';
18
+ }
19
+ export class JuejinDraftQueue {
20
+ #drafts = new Map();
21
+ #file;
22
+ #onPersistError;
23
+ capacity;
24
+ #seq = 0;
25
+ constructor(options = {}) {
26
+ const resolved = typeof options === 'number' ? { capacity: options } : options;
27
+ this.capacity = Math.max(1, resolved.capacity ?? 200);
28
+ this.#file = resolved.file;
29
+ this.#onPersistError = resolved.onPersistError;
30
+ this.#load();
31
+ }
32
+ /** 是否落盘。 */
33
+ get persistent() {
34
+ return this.#file !== undefined;
35
+ }
36
+ #load() {
37
+ if (!this.#file)
38
+ return;
39
+ const raw = readJson(this.#file);
40
+ if (!raw || !Array.isArray(raw.drafts))
41
+ return;
42
+ if (typeof raw.seq === 'number' && Number.isFinite(raw.seq))
43
+ this.#seq = Math.max(0, Math.floor(raw.seq));
44
+ for (const entry of raw.drafts) {
45
+ if (!isDraft(entry))
46
+ continue;
47
+ const { context, status, interrupted, ...rest } = entry;
48
+ const restored = Object.freeze({
49
+ ...rest,
50
+ ...(context && typeof context.author === 'string' && typeof context.text === 'string'
51
+ ? { context: Object.freeze({ author: context.author, text: context.text }) } : {}),
52
+ status: 'pending',
53
+ ...(status === 'sending' || interrupted ? { interrupted: true } : {}),
54
+ });
55
+ this.#drafts.set(restored.id, restored);
56
+ }
57
+ this.#evict();
58
+ }
59
+ #persist() {
60
+ if (!this.#file)
61
+ return;
62
+ try {
63
+ if (this.#drafts.size === 0 && this.#seq === 0) {
64
+ removeFile(this.#file);
65
+ return;
66
+ }
67
+ const data = { version: 1, seq: this.#seq, drafts: [...this.#drafts.values()] };
68
+ writeJsonAtomic(this.#file, data, 0o600);
69
+ }
70
+ catch (error) {
71
+ this.#onPersistError?.(error);
72
+ }
73
+ }
74
+ #evict() {
75
+ while (this.#drafts.size > this.capacity) {
76
+ let removed = false;
77
+ // 优先挤掉最老的 pending 草稿,不动正在发送的。
78
+ for (const [id, draft] of this.#drafts) {
79
+ if (draft.status !== 'sending') {
80
+ this.#drafts.delete(id);
81
+ removed = true;
82
+ break;
83
+ }
84
+ }
85
+ if (!removed)
86
+ break;
87
+ }
88
+ }
89
+ add(input, now = Date.now()) {
90
+ const draft = Object.freeze({
91
+ ...input,
92
+ id: `d${(++this.#seq).toString(36)}${(now % 1296).toString(36).padStart(2, '0')}`,
93
+ status: 'pending',
94
+ createdAt: now,
95
+ });
96
+ this.#drafts.set(draft.id, draft);
97
+ this.#evict();
98
+ this.#persist();
99
+ return draft;
100
+ }
101
+ list() {
102
+ return Object.freeze([...this.#drafts.values()]);
103
+ }
104
+ get(id) {
105
+ return this.#drafts.get(id);
106
+ }
107
+ /**
108
+ * 原地替换字段(id / createdAt 不变)。草稿不存在时返回 undefined。
109
+ * `interrupted: true` 用于「发送结果未知」;人工改写正文会清掉这个标记。
110
+ */
111
+ update(id, patch, now = Date.now()) {
112
+ const current = this.#drafts.get(id);
113
+ if (!current)
114
+ return undefined;
115
+ const { interrupted, ...rest } = current;
116
+ const { interrupted: markInterrupted, ...fields } = patch;
117
+ // 人工改写过正文后不再提示「可能已发出」——审批人已经看过了。
118
+ const keepInterrupted = markInterrupted === true || (interrupted && fields.message === undefined);
119
+ const next = Object.freeze({ ...rest, ...fields, ...(keepInterrupted ? { interrupted: true } : {}), updatedAt: now });
120
+ this.#drafts.set(id, next);
121
+ this.#persist();
122
+ return next;
123
+ }
124
+ discard(id) {
125
+ const removed = this.#drafts.delete(id);
126
+ if (removed)
127
+ this.#persist();
128
+ return removed;
129
+ }
130
+ clear() {
131
+ this.#drafts.clear();
132
+ this.#persist();
133
+ }
134
+ get size() {
135
+ return this.#drafts.size;
136
+ }
137
+ }
@@ -0,0 +1,16 @@
1
+ import type { FeatureId, PluginId } from 'zhin.js';
2
+ import type { JuejinEndpoint } from './endpoint.js';
3
+ /** Closed review/login view; no cookie, config, fetch or mutable account directory. */
4
+ export type JuejinEndpointView = Pick<JuejinEndpoint, 'localId' | 'login' | 'persistent' | 'articles' | 'sendMode' | 'circuitBreaker' | 'approveDraft' | 'editDraft' | 'discardDraft' | 'recheckLogin'> & {
5
+ readonly drafts: Pick<JuejinEndpoint['drafts'], 'get' | 'list' | 'size'>;
6
+ };
7
+ export type JuejinEndpointDirectory = ReadonlyMap<string, JuejinEndpointView>;
8
+ export interface JuejinDirectoryContext {
9
+ readonly owner: PluginId | Readonly<{
10
+ id: PluginId;
11
+ }>;
12
+ readonly signal: AbortSignal;
13
+ project(feature: FeatureId): unknown;
14
+ }
15
+ /** Resolve from the authenticated owner's Adapter; the returned Map is only a request-local projection. */
16
+ export declare function juejinEndpoints(context: JuejinDirectoryContext): JuejinEndpointDirectory;
@@ -0,0 +1,63 @@
1
+ import { adapterFeatureId, isAdapterIndex, isOwnedAdapterDirectory, AdapterManagementError } from 'zhin.js/adapter';
2
+ import { JuejinAdapter } from './adapter.js';
3
+ /** Resolve from the authenticated owner's Adapter; the returned Map is only a request-local projection. */
4
+ export function juejinEndpoints(context) {
5
+ context.signal.throwIfAborted();
6
+ const owner = typeof context.owner === 'string' ? context.owner : context.owner.id;
7
+ const projection = context.project(adapterFeatureId);
8
+ if (!isAdapterIndex(projection) && !isOwnedAdapterDirectory(projection))
9
+ throw new AdapterManagementError('unavailable', 'Juejin Adapter directory is unavailable');
10
+ if (isOwnedAdapterDirectory(projection) && projection.owner !== owner)
11
+ throw new AdapterManagementError('unavailable', 'Juejin directory owner mismatch');
12
+ const adapters = projection.adapters().filter((adapter) => adapter.descriptor.owner === owner && adapter instanceof JuejinAdapter);
13
+ if (adapters.length !== 1)
14
+ throw new AdapterManagementError('unavailable', 'Juejin Adapter cannot be uniquely selected');
15
+ const adapter = adapters[0];
16
+ const rows = new Map();
17
+ for (const child of adapter.runtimeEntries()) {
18
+ const endpoint = child.endpoint;
19
+ if (!endpoint || !child.started || child.stopped)
20
+ continue;
21
+ const canonicalId = child.desired.canonicalId;
22
+ const read = () => {
23
+ context.signal.throwIfAborted();
24
+ if (adapter.getEndpoint(child.desired.instanceId) !== endpoint)
25
+ throw new AdapterManagementError('closed', 'Juejin Endpoint has retired');
26
+ return endpoint;
27
+ };
28
+ const runSync = (operation) => {
29
+ const lease = adapter.acquire(canonicalId, context.signal, endpoint);
30
+ try {
31
+ lease.assertActive();
32
+ return operation(lease.endpoint);
33
+ }
34
+ finally {
35
+ lease.release();
36
+ }
37
+ };
38
+ const runAsync = async (operation) => {
39
+ const lease = adapter.acquire(canonicalId, context.signal, endpoint);
40
+ try {
41
+ return await lease.run(operation);
42
+ }
43
+ finally {
44
+ lease.release();
45
+ }
46
+ };
47
+ const view = Object.freeze({
48
+ get localId() { return read().localId; }, get login() { return read().login; },
49
+ get persistent() { return read().persistent; }, get articles() { return read().articles; },
50
+ get sendMode() { return read().sendMode; },
51
+ get circuitBreaker() { return read().circuitBreaker; },
52
+ drafts: Object.freeze({
53
+ get: id => read().drafts.get(id), list: () => read().drafts.list(), get size() { return read().drafts.size; },
54
+ }),
55
+ approveDraft: id => runAsync(endpoint => endpoint.approveDraft(id)),
56
+ editDraft: (id, message) => runSync(endpoint => endpoint.editDraft(id, message)),
57
+ discardDraft: id => runSync(endpoint => endpoint.discardDraft(id)),
58
+ recheckLogin: () => runAsync(endpoint => endpoint.recheckLogin()),
59
+ });
60
+ rows.set(child.desired.endpointId, view);
61
+ }
62
+ return rows;
63
+ }