@zhin.js/adapter-juejin 0.0.0-stage → 0.1.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.
Files changed (65) hide show
  1. package/README.md +145 -2
  2. package/adapters/juejin/index.js +19 -0
  3. package/adapters/juejin/index.ts +22 -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
@@ -0,0 +1,192 @@
1
+ import type { EndpointProfileConfiguration } from 'zhin.js/adapter';
2
+ /**
3
+ * 掘金(Juejin)评论协议辅助(Experimental,非官方 Web 接口)。
4
+ *
5
+ * 契约见 docs/adr/0035-comment-reply-adapter-contract.md(掘金是第二个实现):
6
+ * - 一条评论楼 = 一个 conversation:`article:<articleId>:<rootCommentId>`,parent = `article:<articleId>`
7
+ * - 文章 id 是 19 位十进制字符串(`https://juejin.cn/post/<id>`),超过 2^53,**绝不能**转成 number。
8
+ * - 读:`POST /interact_api/v1/comment/list`(`item_type=2` 文章,`sort=0` 最新在前),一级评论 + 每楼内联的前几条回复。
9
+ * - 写:`POST /interact_api/v1/reply/publish`(只回复已有评论楼,带 `reply_to_comment_id`;回复楼中楼时再带 `reply_to_reply_id`)。
10
+ */
11
+ import type { ConversationRef } from '@zhin.js/im-contract';
12
+ /** 文章评论区 item_type(`interact_api` 里 2 = 文章,4 = 沸点;本 adapter 只支持文章)。 */
13
+ export declare const JUEJIN_ARTICLE_ITEM_TYPE = 2;
14
+ /** 掘金 Web 端 client_type / aid。 */
15
+ export declare const JUEJIN_CLIENT_TYPE = 2608;
16
+ /** 回复长度上限(保守取值;超过直接拒绝,不截断)。 */
17
+ export declare const JUEJIN_COMMENT_MAX_LENGTH = 1000;
18
+ /** 默认轮询间隔下限(秒)。 */
19
+ export declare const JUEJIN_MIN_POLL_INTERVAL_SEC = 60;
20
+ /** 单次拉取的最新一级评论条数(接口上限 50;默认 20)。 */
21
+ export declare const JUEJIN_COMMENT_PAGE_SIZE = 20;
22
+ /** 未登录 / Cookie 失效:`{"err_no":403,"err_msg":"must login"}`。 */
23
+ export declare const JUEJIN_LOGIN_REQUIRED_CODE = 403;
24
+ /**
25
+ * 被视为风控 / 频控信号的 HTTP 状态:立即熔断,不自动重试。
26
+ * 403 / 429 = 网关拦截或限流(与 `err_no: 403` 未登录不同:那是 HTTP 200 + JSON)。
27
+ */
28
+ export declare const JUEJIN_RISK_HTTP_STATUSES: ReadonlySet<number>;
29
+ /**
30
+ * `err_msg` 命中这些关键字时视为风控(掘金没有公开的风控错误码表,只能按文案判断,宁可多熔断):
31
+ * 频繁 / 过快 / 太快 / 风控 / 验证码 / 人机 / 安全验证 / captcha / verify / too many / too frequent / rate limit。
32
+ */
33
+ export declare const JUEJIN_RISK_MESSAGE: RegExp;
34
+ /** 响应不是 JSON(例如被重定向到验证页)时使用的伪错误码。 */
35
+ export declare const JUEJIN_NON_JSON_CODE = -2;
36
+ export type JuejinSendMode = 'review' | 'off';
37
+ /** 审批后两次真实发送之间的默认最小间隔(秒)与下限。 */
38
+ export declare const JUEJIN_DEFAULT_SEND_INTERVAL_SEC = 30;
39
+ export declare const JUEJIN_MIN_SEND_INTERVAL_SEC = 10;
40
+ /** One endpoint config after AdapterIndex expands `<instanceKey>.endpoints`. */
41
+ export interface JuejinEndpointConfig extends EndpointProfileConfiguration {
42
+ readonly id: string;
43
+ /** @internal Assigned only for fresh Adapter identities; imported records retain their original data path. */
44
+ readonly stateDirectoryId?: string;
45
+ /** 掘金网页登录态 Cookie 整串(至少含 `sessionid`)。请用 `${JUEJIN_COOKIE}` 注入,切勿写进仓库。 */
46
+ readonly cookie?: string;
47
+ /** 白名单文章:文章 id(字符串)或 `https://juejin.cn/post/<id>` 链接。只轮询这些文章。 */
48
+ readonly articles?: readonly (string | number)[];
49
+ /** 轮询间隔秒数,最小 60。 */
50
+ readonly pollIntervalSec?: number;
51
+ /** 出站模式:`review`(默认,进草稿队列)/ `off`(禁止出站)。 */
52
+ readonly sendMode?: JuejinSendMode;
53
+ /** 审批后两次真实发送的最小间隔秒数(默认 30,最小 10)。 */
54
+ readonly minSendIntervalSec?: number;
55
+ /** 草稿的本地目录(默认 `data/juejin`,相对进程工作目录)。不要放进仓库。 */
56
+ readonly dataDir?: string;
57
+ /** 新草稿 / 登录失效时主动提醒到哪个聊天会话(可选)。 */
58
+ readonly notify?: JuejinNotifyTarget;
59
+ readonly apiBaseUrl?: string;
60
+ }
61
+ /** 主动提醒目标:另一个已接入聊天平台里的会话(通常是你和机器人的私聊)。 */
62
+ export interface JuejinNotifyTarget {
63
+ readonly adapter: string;
64
+ readonly endpoint: string;
65
+ readonly kind?: 'private' | 'group' | 'channel';
66
+ readonly id: string | number;
67
+ }
68
+ export interface ResolvedJuejinNotifyTarget {
69
+ readonly adapter: string;
70
+ readonly endpoint: string;
71
+ readonly kind: 'private' | 'group' | 'channel';
72
+ readonly id: string;
73
+ }
74
+ export interface ResolvedJuejinConfig {
75
+ readonly context: 'juejin';
76
+ readonly id: string;
77
+ readonly stateDirectoryId?: string;
78
+ readonly cookie?: string;
79
+ /** 白名单文章 id(十进制字符串,去重后保持配置顺序)。 */
80
+ readonly articles: readonly string[];
81
+ readonly pollIntervalMs: number;
82
+ readonly sendMode: JuejinSendMode;
83
+ readonly minSendIntervalMs: number;
84
+ readonly dataDir: string;
85
+ readonly notify?: ResolvedJuejinNotifyTarget;
86
+ readonly apiBaseUrl: string;
87
+ }
88
+ export declare const JUEJIN_DEFAULT_DATA_DIR = "data/juejin";
89
+ export declare const JUEJIN_DEFAULT_API_BASE_URL = "https://api.juejin.cn";
90
+ /** 掘金 id(文章 / 评论 / 回复 / 用户)都是十进制字符串;`'0'` 表示「没有」。 */
91
+ export declare function isJuejinId(value: unknown): value is string;
92
+ /**
93
+ * 白名单文章:`7129482663794049032` 或 `https://juejin.cn/post/7129482663794049032`(可带 query / hash)。
94
+ * YAML 里不加引号的 19 位数字会丢精度,直接拒绝,避免回复到错误的文章。
95
+ */
96
+ export declare function parseArticleRef(value: string | number): string;
97
+ export declare function resolveJuejinConfig(config: JuejinEndpointConfig): ResolvedJuejinConfig;
98
+ export interface JuejinApiEnvelope<T> {
99
+ readonly err_no: number;
100
+ readonly err_msg?: string;
101
+ readonly data?: T;
102
+ readonly cursor?: string;
103
+ readonly count?: number;
104
+ readonly has_more?: boolean;
105
+ }
106
+ export interface JuejinUserInfo {
107
+ readonly user_id?: string;
108
+ readonly user_name?: string;
109
+ readonly avatar_large?: string;
110
+ }
111
+ /** `comment/list` 的一条楼中楼回复(`reply_infos[i]`)。注意顶层 `reply_id` 是会丢精度的 JSON number,只用 `reply_info.reply_id`。 */
112
+ export interface JuejinReplyEntry {
113
+ readonly reply_id?: unknown;
114
+ readonly reply_info?: {
115
+ readonly reply_id?: string;
116
+ /** 所在评论楼(一级评论 id)。 */
117
+ readonly reply_comment_id?: string;
118
+ readonly user_id?: string;
119
+ /** 回复的是哪条楼中楼回复;`'0'` = 直接回复一级评论。 */
120
+ readonly reply_to_reply_id?: string;
121
+ readonly reply_to_user_id?: string;
122
+ readonly item_id?: string;
123
+ readonly item_type?: number;
124
+ readonly reply_content?: string;
125
+ readonly ctime?: number;
126
+ readonly digg_count?: number;
127
+ };
128
+ readonly user_info?: JuejinUserInfo;
129
+ /** 回复者是否文章作者。 */
130
+ readonly is_author?: boolean;
131
+ }
132
+ /** `comment/list` 的一条一级评论。 */
133
+ export interface JuejinCommentEntry {
134
+ readonly comment_id?: string;
135
+ readonly comment_info?: {
136
+ readonly comment_id?: string;
137
+ readonly user_id?: string;
138
+ readonly item_id?: string;
139
+ readonly item_type?: number;
140
+ readonly comment_content?: string;
141
+ readonly ctime?: number;
142
+ readonly digg_count?: number;
143
+ readonly reply_count?: number;
144
+ };
145
+ readonly user_info?: JuejinUserInfo;
146
+ /** 评论者是否文章作者。 */
147
+ readonly is_author?: boolean;
148
+ /** 内联预览:每楼最早的几条回复(实测 2 条),不是全量。 */
149
+ readonly reply_infos?: readonly JuejinReplyEntry[] | null;
150
+ }
151
+ /** 归一化后的评论(一级评论或楼中楼回复)。 */
152
+ export interface JuejinComment {
153
+ readonly kind: 'comment' | 'reply';
154
+ /** 评论 id(一级)或回复 id(楼中楼)。 */
155
+ readonly id: string;
156
+ readonly articleId: string;
157
+ /** 所在评论楼的根(一级评论 id)。 */
158
+ readonly rootId: string;
159
+ /** 直接回复的对象:楼中楼回复的 `reply_to_reply_id`,否则为根。 */
160
+ readonly parentId: string;
161
+ readonly userId: string;
162
+ readonly userName?: string;
163
+ readonly text: string;
164
+ /** 秒级时间戳。 */
165
+ readonly ctime: number;
166
+ readonly diggCount: number;
167
+ /** 平台标记的「作者」评论(白名单是自己的文章,所以作者 = 自己)。 */
168
+ readonly isAuthor: boolean;
169
+ }
170
+ /**
171
+ * 把一页 `comment/list` 摊平成评论 + 内联回复。缺 id、id 不是十进制字符串、不属于这篇文章的条目一律丢弃
172
+ * (宁可漏,也不回错楼)。
173
+ */
174
+ export declare function flattenComments(articleId: string, entries: readonly JuejinCommentEntry[] | null | undefined): JuejinComment[];
175
+ /** 评论区资源会话 id(评论楼的 parent):`article:<articleId>`。 */
176
+ export declare function articleResourceId(articleId: string): string;
177
+ /** 评论楼会话 id:`article:<articleId>:<rootCommentId>`。 */
178
+ export declare function threadConversationId(articleId: string, rootCommentId: string): string;
179
+ export interface ParsedThreadId {
180
+ readonly articleId: string;
181
+ readonly root: string;
182
+ }
183
+ export declare function parseThreadConversationId(id: string): ParsedThreadId | undefined;
184
+ export declare function juejinThreadConversation(endpointKey: string, articleId: string, rootCommentId: string): ConversationRef;
185
+ /** 文章页链接(掘金没有可靠的单条评论锚点,审批时到文章评论区对照)。 */
186
+ export declare function articleUrl(articleId: string): string;
187
+ /** 出站 payload 里 `reply` 段指向的评论 / 回复 id(十进制字符串才认)。 */
188
+ export declare function outboundReplyTarget(payload: unknown): string | undefined;
189
+ /** 出站:评论只支持纯文本;mention 降级为 `@昵称`。 */
190
+ export declare function formatOutboundText(payload: unknown): string;
191
+ /** 审批前校验回复正文;合法返回 undefined。 */
192
+ export declare function validateReplyText(message: string): string | undefined;
@@ -0,0 +1,214 @@
1
+ /** 文章评论区 item_type(`interact_api` 里 2 = 文章,4 = 沸点;本 adapter 只支持文章)。 */
2
+ export const JUEJIN_ARTICLE_ITEM_TYPE = 2;
3
+ /** 掘金 Web 端 client_type / aid。 */
4
+ export const JUEJIN_CLIENT_TYPE = 2608;
5
+ /** 回复长度上限(保守取值;超过直接拒绝,不截断)。 */
6
+ export const JUEJIN_COMMENT_MAX_LENGTH = 1000;
7
+ /** 默认轮询间隔下限(秒)。 */
8
+ export const JUEJIN_MIN_POLL_INTERVAL_SEC = 60;
9
+ /** 单次拉取的最新一级评论条数(接口上限 50;默认 20)。 */
10
+ export const JUEJIN_COMMENT_PAGE_SIZE = 20;
11
+ /** 未登录 / Cookie 失效:`{"err_no":403,"err_msg":"must login"}`。 */
12
+ export const JUEJIN_LOGIN_REQUIRED_CODE = 403;
13
+ /**
14
+ * 被视为风控 / 频控信号的 HTTP 状态:立即熔断,不自动重试。
15
+ * 403 / 429 = 网关拦截或限流(与 `err_no: 403` 未登录不同:那是 HTTP 200 + JSON)。
16
+ */
17
+ export const JUEJIN_RISK_HTTP_STATUSES = new Set([403, 429]);
18
+ /**
19
+ * `err_msg` 命中这些关键字时视为风控(掘金没有公开的风控错误码表,只能按文案判断,宁可多熔断):
20
+ * 频繁 / 过快 / 太快 / 风控 / 验证码 / 人机 / 安全验证 / captcha / verify / too many / too frequent / rate limit。
21
+ */
22
+ export const JUEJIN_RISK_MESSAGE = /频繁|过快|太快|风控|验证码|人机|安全验证|captcha|verify|too many|too frequent|rate limit/iu;
23
+ /** 响应不是 JSON(例如被重定向到验证页)时使用的伪错误码。 */
24
+ export const JUEJIN_NON_JSON_CODE = -2;
25
+ /** 审批后两次真实发送之间的默认最小间隔(秒)与下限。 */
26
+ export const JUEJIN_DEFAULT_SEND_INTERVAL_SEC = 30;
27
+ export const JUEJIN_MIN_SEND_INTERVAL_SEC = 10;
28
+ export const JUEJIN_DEFAULT_DATA_DIR = 'data/juejin';
29
+ export const JUEJIN_DEFAULT_API_BASE_URL = 'https://api.juejin.cn';
30
+ const ID = /^[1-9]\d{0,19}$/u;
31
+ /** 掘金 id(文章 / 评论 / 回复 / 用户)都是十进制字符串;`'0'` 表示「没有」。 */
32
+ export function isJuejinId(value) {
33
+ return typeof value === 'string' && ID.test(value);
34
+ }
35
+ /**
36
+ * 白名单文章:`7129482663794049032` 或 `https://juejin.cn/post/7129482663794049032`(可带 query / hash)。
37
+ * YAML 里不加引号的 19 位数字会丢精度,直接拒绝,避免回复到错误的文章。
38
+ */
39
+ export function parseArticleRef(value) {
40
+ if (typeof value === 'number' && !Number.isSafeInteger(value)) {
41
+ throw new TypeError(`Juejin article id ${String(value)} lost precision; quote it as a string in the config`);
42
+ }
43
+ const raw = String(value).trim();
44
+ if (ID.test(raw))
45
+ return raw;
46
+ const url = /^https?:\/\/(?:www\.)?juejin\.cn\/post\/([1-9]\d{0,19})(?:[/?#].*)?$/iu.exec(raw);
47
+ if (url)
48
+ return url[1];
49
+ throw new TypeError(`Juejin article must be an article id or https://juejin.cn/post/<id> link, got "${raw}"`);
50
+ }
51
+ function resolveNotifyTarget(value) {
52
+ if (value === undefined || value === null)
53
+ return undefined;
54
+ const adapter = typeof value.adapter === 'string' ? value.adapter.trim() : '';
55
+ const endpoint = typeof value.endpoint === 'string' ? value.endpoint.trim() : String(value.endpoint ?? '').trim();
56
+ const id = String(value.id ?? '').trim();
57
+ const kind = value.kind ?? 'private';
58
+ if (!adapter || !endpoint || !id)
59
+ throw new TypeError('Juejin notify requires adapter, endpoint and id');
60
+ if (kind !== 'private' && kind !== 'group' && kind !== 'channel') {
61
+ throw new TypeError(`Juejin notify.kind must be private, group or channel, got "${String(kind)}"`);
62
+ }
63
+ return Object.freeze({ adapter, endpoint, kind, id });
64
+ }
65
+ function trimTrailingSlashes(value) {
66
+ let end = value.length;
67
+ while (end > 0 && value.charCodeAt(end - 1) === 47 /* '/' */)
68
+ end -= 1;
69
+ return value.slice(0, end);
70
+ }
71
+ /** Cookie 只允许单行;换行会被当成 header 注入。 */
72
+ function resolveCookie(value) {
73
+ const cookie = typeof value === 'string' ? value.trim() : '';
74
+ if (!cookie)
75
+ return undefined;
76
+ if (/[\r\n]/u.test(cookie))
77
+ throw new TypeError('Juejin cookie must be a single line');
78
+ return cookie;
79
+ }
80
+ export function resolveJuejinConfig(config) {
81
+ const id = typeof config.id === 'string' ? config.id.trim() : '';
82
+ if (!id)
83
+ throw new TypeError('Juejin endpoint requires a non-empty id');
84
+ const sendMode = config.sendMode ?? 'review';
85
+ if (sendMode !== 'review' && sendMode !== 'off') {
86
+ // auto 直发刻意不开放(ADR 0035 §3)。
87
+ throw new TypeError(`Juejin sendMode must be "review" or "off", got "${String(sendMode)}"`);
88
+ }
89
+ const interval = Math.max(JUEJIN_MIN_POLL_INTERVAL_SEC, Number.isFinite(config.pollIntervalSec) ? Number(config.pollIntervalSec) : JUEJIN_MIN_POLL_INTERVAL_SEC);
90
+ const sendInterval = Math.max(JUEJIN_MIN_SEND_INTERVAL_SEC, Number.isFinite(config.minSendIntervalSec) ? Number(config.minSendIntervalSec) : JUEJIN_DEFAULT_SEND_INTERVAL_SEC);
91
+ const cookie = resolveCookie(config.cookie);
92
+ const notify = resolveNotifyTarget(config.notify);
93
+ return {
94
+ context: 'juejin',
95
+ id,
96
+ ...(config.stateDirectoryId ? { stateDirectoryId: config.stateDirectoryId } : {}),
97
+ ...(cookie ? { cookie } : {}),
98
+ articles: Object.freeze([...new Set((config.articles ?? []).map(parseArticleRef))]),
99
+ pollIntervalMs: interval * 1000,
100
+ sendMode,
101
+ minSendIntervalMs: sendInterval * 1000,
102
+ dataDir: config.dataDir?.trim() || JUEJIN_DEFAULT_DATA_DIR,
103
+ ...(notify ? { notify } : {}),
104
+ apiBaseUrl: trimTrailingSlashes(config.apiBaseUrl?.trim() || JUEJIN_DEFAULT_API_BASE_URL),
105
+ };
106
+ }
107
+ /**
108
+ * 把一页 `comment/list` 摊平成评论 + 内联回复。缺 id、id 不是十进制字符串、不属于这篇文章的条目一律丢弃
109
+ * (宁可漏,也不回错楼)。
110
+ */
111
+ export function flattenComments(articleId, entries) {
112
+ const out = [];
113
+ for (const entry of entries ?? []) {
114
+ const info = entry?.comment_info;
115
+ const id = info?.comment_id ?? entry?.comment_id;
116
+ const userId = info?.user_id ?? entry?.user_info?.user_id;
117
+ if (!isJuejinId(id) || !isJuejinId(userId) || info?.item_id !== articleId)
118
+ continue;
119
+ out.push(Object.freeze({
120
+ kind: 'comment', id, articleId, rootId: id, parentId: id, userId,
121
+ ...(entry.user_info?.user_name ? { userName: entry.user_info.user_name } : {}),
122
+ text: info.comment_content ?? '', ctime: Number(info.ctime) || 0, diggCount: Number(info.digg_count) || 0,
123
+ isAuthor: entry.is_author === true,
124
+ }));
125
+ for (const child of entry.reply_infos ?? []) {
126
+ const reply = child?.reply_info;
127
+ if (!reply || !isJuejinId(reply.reply_id) || reply.reply_comment_id !== id || !isJuejinId(reply.user_id))
128
+ continue;
129
+ if (reply.item_id !== undefined && reply.item_id !== articleId)
130
+ continue;
131
+ const target = reply.reply_to_reply_id;
132
+ out.push(Object.freeze({
133
+ kind: 'reply', id: reply.reply_id, articleId, rootId: id,
134
+ parentId: isJuejinId(target) ? target : id, userId: reply.user_id,
135
+ ...(child.user_info?.user_name ? { userName: child.user_info.user_name } : {}),
136
+ text: reply.reply_content ?? '', ctime: Number(reply.ctime) || 0, diggCount: Number(reply.digg_count) || 0,
137
+ isAuthor: child.is_author === true,
138
+ }));
139
+ }
140
+ }
141
+ return out;
142
+ }
143
+ // ── Conversation ids ─────────────────────────────────────────────────────────
144
+ /** 评论区资源会话 id(评论楼的 parent):`article:<articleId>`。 */
145
+ export function articleResourceId(articleId) {
146
+ return `article:${articleId}`;
147
+ }
148
+ /** 评论楼会话 id:`article:<articleId>:<rootCommentId>`。 */
149
+ export function threadConversationId(articleId, rootCommentId) {
150
+ return `${articleResourceId(articleId)}:${rootCommentId}`;
151
+ }
152
+ export function parseThreadConversationId(id) {
153
+ const match = /^article:([1-9]\d{0,19}):([1-9]\d{0,19})$/u.exec(id);
154
+ if (!match)
155
+ return undefined;
156
+ return { articleId: match[1], root: match[2] };
157
+ }
158
+ export function juejinThreadConversation(endpointKey, articleId, rootCommentId) {
159
+ return {
160
+ endpoint: { id: endpointKey, adapter: endpointKey.split('\0')[0] ?? endpointKey },
161
+ kind: 'channel',
162
+ id: threadConversationId(articleId, rootCommentId),
163
+ parent: { kind: 'channel', id: articleResourceId(articleId) },
164
+ };
165
+ }
166
+ /** 文章页链接(掘金没有可靠的单条评论锚点,审批时到文章评论区对照)。 */
167
+ export function articleUrl(articleId) {
168
+ return `https://juejin.cn/post/${articleId}`;
169
+ }
170
+ /** 出站 payload 里 `reply` 段指向的评论 / 回复 id(十进制字符串才认)。 */
171
+ export function outboundReplyTarget(payload) {
172
+ const items = Array.isArray(payload) ? payload : [payload];
173
+ for (const item of items) {
174
+ if (!item || typeof item !== 'object')
175
+ continue;
176
+ const seg = item;
177
+ if (seg.type !== 'reply')
178
+ continue;
179
+ const id = String(seg.data?.id ?? seg.data?.message_id ?? '').trim();
180
+ if (isJuejinId(id))
181
+ return id;
182
+ }
183
+ return undefined;
184
+ }
185
+ /** 出站:评论只支持纯文本;mention 降级为 `@昵称`。 */
186
+ export function formatOutboundText(payload) {
187
+ if (typeof payload === 'string')
188
+ return payload.trim();
189
+ if (payload == null)
190
+ return '';
191
+ const items = Array.isArray(payload) ? payload : [payload];
192
+ return items.map((item) => {
193
+ if (typeof item === 'string')
194
+ return item;
195
+ const seg = item;
196
+ switch (seg.type) {
197
+ case 'text':
198
+ return String(seg.data?.text ?? '');
199
+ case 'mention':
200
+ case 'at':
201
+ return `@${String(seg.data?.name ?? seg.data?.id ?? '')} `;
202
+ default:
203
+ return '';
204
+ }
205
+ }).join('').trim();
206
+ }
207
+ /** 审批前校验回复正文;合法返回 undefined。 */
208
+ export function validateReplyText(message) {
209
+ if (!message)
210
+ return 'Empty Juejin reply';
211
+ if ([...message].length > JUEJIN_COMMENT_MAX_LENGTH)
212
+ return `Juejin reply exceeds ${JUEJIN_COMMENT_MAX_LENGTH} characters`;
213
+ return undefined;
214
+ }
@@ -0,0 +1,29 @@
1
+ /** Draft review presentation; endpoint methods remain the approval and delivery authority. */
2
+ import { type Locale } from 'zhin.js/i18n';
3
+ import type { JuejinReplyDraft } from './drafts.js';
4
+ import { type JuejinNotice } from './endpoint.js';
5
+ import type { JuejinEndpointDirectory, JuejinEndpointView } from './endpoint-directory.js';
6
+ export interface DraftPresentation {
7
+ readonly locale?: Locale;
8
+ /** Bound to the current command index, owner namespace and actual chat prefix. */
9
+ readonly formatCommand?: (localPath: string) => string | undefined;
10
+ }
11
+ /** 主动提醒的文案(新草稿 / 登录失效)。 */
12
+ export declare function formatNotice(notice: JuejinNotice, presentation?: DraftPresentation): string;
13
+ /** 草稿所在文章的链接(Console / 列表展示)。 */
14
+ export declare function draftArticleUrl(draft: Pick<JuejinReplyDraft, 'articleId'>): string;
15
+ export declare function formatDraft(draft: JuejinReplyDraft, options?: {
16
+ readonly full?: boolean;
17
+ readonly locale?: Locale;
18
+ }): string;
19
+ export declare function findDraft(registry: JuejinEndpointDirectory, id: string): {
20
+ endpoint: JuejinEndpointView;
21
+ draft: JuejinReplyDraft;
22
+ } | undefined;
23
+ export declare function draftEditUsage(presentation?: DraftPresentation): string;
24
+ export declare function listDraftsText(registry: JuejinEndpointDirectory, presentation?: DraftPresentation): string;
25
+ export declare function showDraftText(registry: JuejinEndpointDirectory, id: string, presentation?: DraftPresentation): string;
26
+ export declare function editDraftText(registry: JuejinEndpointDirectory, id: string, message: string, presentation?: DraftPresentation): string;
27
+ export declare function discardDraftText(registry: JuejinEndpointDirectory, id: string, presentation?: DraftPresentation): string;
28
+ export declare function approveDraftText(registry: JuejinEndpointDirectory, id: string, presentation?: DraftPresentation): Promise<string>;
29
+ export declare function reviewErrorText(id: string, error: unknown, locale?: Locale): string;
package/lib/review.js ADDED
@@ -0,0 +1,156 @@
1
+ /** Draft review presentation; endpoint methods remain the approval and delivery authority. */
2
+ import { createMessages } from 'zhin.js/i18n';
3
+ import { JuejinReviewError } from './endpoint.js';
4
+ import { JUEJIN_COMMENT_MAX_LENGTH, articleUrl } from './protocol.js';
5
+ const LIST_LIMIT = 10;
6
+ const t = createMessages({
7
+ sending: { 'zh-CN': '(发送中)', en: ' (sending)' },
8
+ interrupted: { 'zh-CN': '(上次发送结果未知,可能已经发出,请先到掘金确认)', en: ' (last send outcome is unknown and may have been posted; check Juejin first)' },
9
+ noticeDraft: { 'zh-CN': '掘金有一条新评论回复等你审批:', en: 'A new Juejin comment reply is waiting for your approval:' },
10
+ noticeReview: { 'zh-CN': '去 Console「掘金评论审批」页处理,或:{command}', en: 'Review it on the Console "Juejin comment review" page, or: {command}' },
11
+ noticeReviewConsole: { 'zh-CN': '去 Console「掘金评论审批」页处理', en: 'Review it on the Console "Juejin comment review" page' },
12
+ noticeExpired: { 'zh-CN': '掘金登录已失效(Cookie 来自配置 / 环境变量),暂时无法发送评论。请更新 Cookie 后重启。', en: 'Your Juejin login has expired (cookie from config or environment), so replies cannot be posted. Update the cookie and restart.' },
13
+ original: { 'zh-CN': ' 原评论 {author}:{text}', en: ' Original comment by {author}: {text}' },
14
+ reply: { 'zh-CN': ' 拟回复:{text}', en: ' Proposed reply: {text}' },
15
+ empty: { 'zh-CN': '没有待审的掘金评论草稿', en: 'No Juejin comment drafts are awaiting review.' },
16
+ more: { 'zh-CN': '(只显示最新 {shown} 条,共 {total} 条)', en: '(Showing the latest {shown} of {total} drafts)' },
17
+ list: { 'zh-CN': '待审草稿 {count} 条:', en: '{count} drafts awaiting review:' },
18
+ sendHint: { 'zh-CN': '发送:{command}', en: 'Send: {command}' },
19
+ editHint: { 'zh-CN': '改写:{command}', en: 'Edit: {command}' },
20
+ dropHint: { 'zh-CN': '丢弃:{command}', en: 'Discard: {command}' },
21
+ content: { 'zh-CN': '<新内容>', en: '<new content>' },
22
+ editUsage: { 'zh-CN': '用法:{command} <id> {content}', en: 'Usage: {command} <id> {content}' },
23
+ editRequired: { 'zh-CN': '请提供草稿的新内容。当前环境无法确定改写指令的调用方式。', en: 'Provide new draft text. The edit command invocation is unavailable in this environment.' },
24
+ missing: { 'zh-CN': '找不到草稿 {id}', en: 'Draft {id} was not found.' },
25
+ edited: { 'zh-CN': '已改写,还没发出:', en: 'Updated; not sent yet:' },
26
+ approveHint: { 'zh-CN': '确认无误后:{command}', en: 'After reviewing, approve with: {command}' },
27
+ dropped: { 'zh-CN': '已丢弃草稿 {id}(未发送)', en: 'Draft {id} discarded without sending.' },
28
+ sent: { 'zh-CN': '已发到掘金:{url}', en: 'Posted to Juejin: {url}' },
29
+ failed: { 'zh-CN': '草稿 {id} 操作失败:{error}', en: 'Draft {id} operation failed: {error}' },
30
+ busy: { 'zh-CN': '草稿 {id} 正在发送,请稍等', en: 'Draft {id} is being sent. Please wait.' },
31
+ disabled: { 'zh-CN': '当前不允许出站(sendMode 为 off,或文章已不在白名单)', en: 'Outbound is not allowed (sendMode is off, or the article is no longer whitelisted).' },
32
+ circuit: { 'zh-CN': '掘金风控熔断,草稿 {id} 已保留未发。请到网页端确认账号状态,处理后重启插件。', en: 'Juejin risk controls stopped this endpoint. Draft {id} is retained and was not sent. Check the account on the website, resolve the issue, then restart the plugin.' },
33
+ cookie: { 'zh-CN': '掘金未登录或登录已失效,无法发送(草稿已保留)。请更新 JUEJIN_COOKIE 后重启。', en: 'Not signed in to Juejin or the login expired, so the draft was kept unsent. Update JUEJIN_COOKIE and restart.' },
34
+ limited: { 'zh-CN': '发得太快了,请 {seconds} 秒后再发(草稿 {id} 已保留)', en: 'Sending too quickly. Try again after {seconds} seconds; draft {id} is retained.' },
35
+ invalid: { 'zh-CN': '草稿内容不合法:回复不能为空,且不能超过 {limit} 个字符。', en: 'Invalid draft: replies must be nonempty and at most {limit} characters.' },
36
+ unknown: { 'zh-CN': '掘金没有确认结果,草稿 {id} 可能已经发出。请先到文章评论区确认,再决定重发或丢弃。', en: 'Juejin did not confirm the result; draft {id} may already be posted. Check the article comments before resending or discarding.' },
37
+ platform: { 'zh-CN': '掘金返回错误,草稿 {id} 已保留:{error}', en: 'Juejin returned an error; draft {id} is retained: {error}' },
38
+ });
39
+ /** 主动提醒的文案(新草稿 / 登录失效)。 */
40
+ export function formatNotice(notice, presentation = {}) {
41
+ const { locale, formatCommand } = presentation;
42
+ switch (notice.kind) {
43
+ case 'draft': {
44
+ const command = formatCommand?.('juejin-draft send');
45
+ return [t(locale, 'noticeDraft'), formatDraft(notice.draft, { locale }),
46
+ command === undefined ? t(locale, 'noticeReviewConsole') : t(locale, 'noticeReview', { command: `${command} ${notice.draft.id}` })].join('\n');
47
+ }
48
+ case 'login_expired':
49
+ return t(locale, 'noticeExpired');
50
+ }
51
+ }
52
+ /** 草稿所在文章的链接(Console / 列表展示)。 */
53
+ export function draftArticleUrl(draft) {
54
+ return articleUrl(draft.articleId);
55
+ }
56
+ function excerpt(text, max = 60) {
57
+ const chars = [...text.replace(/\s+/g, ' ').trim()];
58
+ return chars.length > max ? `${chars.slice(0, max).join('')}…` : chars.join('');
59
+ }
60
+ export function formatDraft(draft, options = {}) {
61
+ const { locale } = options;
62
+ const lines = [`[${draft.id}] ${draftArticleUrl(draft)}${draft.status === 'sending' ? t(locale, 'sending') : ''}${draft.interrupted ? t(locale, 'interrupted') : ''}`];
63
+ if (draft.context)
64
+ lines.push(t(locale, 'original', { author: draft.context.author, text: options.full ? draft.context.text : excerpt(draft.context.text) }));
65
+ lines.push(t(locale, 'reply', { text: options.full ? draft.message : excerpt(draft.message) }));
66
+ return lines.join('\n');
67
+ }
68
+ export function findDraft(registry, id) {
69
+ for (const endpoint of registry.values()) {
70
+ const draft = endpoint.drafts.get(id);
71
+ if (draft)
72
+ return { endpoint, draft };
73
+ }
74
+ return undefined;
75
+ }
76
+ export function draftEditUsage(presentation = {}) {
77
+ const command = presentation.formatCommand?.('juejin-draft edit');
78
+ return command === undefined ? t(presentation.locale, 'editRequired')
79
+ : t(presentation.locale, 'editUsage', { command, content: t(presentation.locale, 'content') });
80
+ }
81
+ export function listDraftsText(registry, presentation = {}) {
82
+ const { locale, formatCommand } = presentation;
83
+ const drafts = [...registry.values()].flatMap(endpoint => endpoint.drafts.list());
84
+ if (drafts.length === 0)
85
+ return t(locale, 'empty');
86
+ const shown = drafts.slice(-LIST_LIMIT);
87
+ const lines = [t(locale, 'list', { count: drafts.length }), ...shown.map(draft => formatDraft(draft, { locale }))];
88
+ if (drafts.length > shown.length)
89
+ lines.push(t(locale, 'more', { shown: shown.length, total: drafts.length }));
90
+ const hints = [];
91
+ for (const [action, key] of [['send', 'sendHint'], ['edit', 'editHint'], ['drop', 'dropHint']]) {
92
+ const command = formatCommand?.(`juejin-draft ${action}`);
93
+ if (command !== undefined)
94
+ hints.push(t(locale, key, { command: `${command} <id>${action === 'edit' ? ` ${t(locale, 'content')}` : ''}` }));
95
+ }
96
+ if (hints.length)
97
+ lines.push('', hints.join('\n'));
98
+ return lines.join('\n');
99
+ }
100
+ export function showDraftText(registry, id, presentation = {}) {
101
+ const found = findDraft(registry, id);
102
+ return found ? formatDraft(found.draft, { full: true, locale: presentation.locale }) : t(presentation.locale, 'missing', { id });
103
+ }
104
+ export function editDraftText(registry, id, message, presentation = {}) {
105
+ const { locale, formatCommand } = presentation;
106
+ const found = findDraft(registry, id);
107
+ if (!found)
108
+ return t(locale, 'missing', { id });
109
+ try {
110
+ const draft = found.endpoint.editDraft(id, message);
111
+ const command = formatCommand?.('juejin-draft send');
112
+ return [t(locale, 'edited'), formatDraft(draft, { full: true, locale }),
113
+ ...(command === undefined ? [] : [t(locale, 'approveHint', { command: `${command} ${id}` })])].join('\n');
114
+ }
115
+ catch (error) {
116
+ return reviewErrorText(id, error, locale);
117
+ }
118
+ }
119
+ export function discardDraftText(registry, id, presentation = {}) {
120
+ const found = findDraft(registry, id);
121
+ if (!found)
122
+ return t(presentation.locale, 'missing', { id });
123
+ try {
124
+ return t(presentation.locale, found.endpoint.discardDraft(id) ? 'dropped' : 'missing', { id });
125
+ }
126
+ catch (error) {
127
+ return reviewErrorText(id, error, presentation.locale);
128
+ }
129
+ }
130
+ export async function approveDraftText(registry, id, presentation = {}) {
131
+ const found = findDraft(registry, id);
132
+ if (!found)
133
+ return t(presentation.locale, 'missing', { id });
134
+ try {
135
+ const { draft } = await found.endpoint.approveDraft(id);
136
+ return `${t(presentation.locale, 'sent', { url: draftArticleUrl(draft) })}\n${excerpt(draft.message, 120)}`;
137
+ }
138
+ catch (error) {
139
+ return reviewErrorText(id, error, presentation.locale);
140
+ }
141
+ }
142
+ export function reviewErrorText(id, error, locale) {
143
+ if (!(error instanceof JuejinReviewError))
144
+ return t(locale, 'failed', { id, error: error instanceof Error ? error.message : String(error) });
145
+ switch (error.code) {
146
+ case 'not_found': return t(locale, 'missing', { id });
147
+ case 'busy': return t(locale, 'busy', { id });
148
+ case 'send_disabled': return t(locale, 'disabled');
149
+ case 'circuit_open': return t(locale, 'circuit', { id });
150
+ case 'no_cookie': return t(locale, 'cookie');
151
+ case 'rate_limited': return t(locale, 'limited', { id, seconds: Math.ceil((error.retryAfterMs ?? 0) / 1000) });
152
+ case 'invalid_message': return t(locale, 'invalid', { limit: JUEJIN_COMMENT_MAX_LENGTH });
153
+ case 'unknown_outcome': return t(locale, 'unknown', { id });
154
+ case 'platform_error': return t(locale, 'platform', { id, error: error.message });
155
+ }
156
+ }
@@ -0,0 +1,9 @@
1
+ import { type EndpointProfile } from 'zhin.js/adapter';
2
+ import type { JuejinClient } from './client.js';
3
+ interface JuejinSelfProfileOptions {
4
+ readonly client: Pick<JuejinClient, 'getSelf'>;
5
+ readonly reportAccount: (id: string) => void;
6
+ }
7
+ /** Experimental Juejin adapter exposes a read-only profile from the logged-in Web identity. */
8
+ export declare function createJuejinSelfProfile({ client, reportAccount }: JuejinSelfProfileOptions): EndpointProfile;
9
+ export {};
@@ -0,0 +1,14 @@
1
+ import { createEndpointOperation } from 'zhin.js/adapter';
2
+ /** Experimental Juejin adapter exposes a read-only profile from the logged-in Web identity. */
3
+ export function createJuejinSelfProfile({ client, reportAccount }) {
4
+ return Object.freeze({
5
+ writableFields: Object.freeze([]),
6
+ async read({ signal }) {
7
+ const self = await createEndpointOperation({ signal }).prepare(() => client.getSelf());
8
+ if (!self)
9
+ throw new Error('Juejin account is not logged in');
10
+ reportAccount(self.userId);
11
+ return { ...(self.userName ? { nickname: self.userName } : {}), ...(self.avatar ? { avatar: self.avatar } : {}) };
12
+ },
13
+ });
14
+ }
@@ -0,0 +1,11 @@
1
+ /** endpoint id → 安全的目录名(只留字母数字和 `_.-`,其余替换成 `_`)。 */
2
+ export declare function endpointDirectoryName(endpointId: string): string;
3
+ export declare function writeJsonAtomic(file: string, value: unknown, mode?: number): void;
4
+ /** 读不到、不是 JSON 都返回 undefined;调用方自行校验结构。 */
5
+ export declare function readJson(file: string): unknown;
6
+ export declare function removeFile(file: string): void;
7
+ export declare class JuejinStateFiles {
8
+ readonly directory: string;
9
+ constructor(rootDir: string, endpointId: string);
10
+ get draftsFile(): string;
11
+ }