@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.
- package/README.md +145 -2
- package/adapters/juejin/index.js +19 -0
- package/adapters/juejin/index.ts +22 -0
- package/commands/juejin-draft/drop/[id]/index.js +13 -0
- package/commands/juejin-draft/drop/[id]/index.ts +13 -0
- package/commands/juejin-draft/edit/[id]/index.js +16 -0
- package/commands/juejin-draft/edit/[id]/index.ts +15 -0
- package/commands/juejin-draft/list/index.js +12 -0
- package/commands/juejin-draft/list/index.ts +12 -0
- package/commands/juejin-draft/send/[id]/index.js +13 -0
- package/commands/juejin-draft/send/[id]/index.ts +13 -0
- package/commands/juejin-draft/show/[id]/index.js +13 -0
- package/commands/juejin-draft/show/[id]/index.ts +13 -0
- package/commands/juejin-login/status/index.js +12 -0
- package/commands/juejin-login/status/index.ts +12 -0
- package/lib/adapter.d.ts +22 -0
- package/lib/adapter.js +43 -0
- package/lib/client.d.ts +83 -0
- package/lib/client.js +192 -0
- package/lib/console.d.ts +16 -0
- package/lib/console.js +172 -0
- package/lib/drafts.d.ts +50 -0
- package/lib/drafts.js +137 -0
- package/lib/endpoint-directory.d.ts +16 -0
- package/lib/endpoint-directory.js +63 -0
- package/lib/endpoint.d.ts +86 -0
- package/lib/endpoint.js +440 -0
- package/lib/index.d.ts +11 -0
- package/lib/index.js +11 -0
- package/lib/juejin-runtime-state.d.ts +4 -0
- package/lib/juejin-runtime-state.js +3 -0
- package/lib/login-text.d.ts +5 -0
- package/lib/login-text.js +25 -0
- package/lib/protocol.d.ts +192 -0
- package/lib/protocol.js +214 -0
- package/lib/review.d.ts +29 -0
- package/lib/review.js +156 -0
- package/lib/self-profile.d.ts +9 -0
- package/lib/self-profile.js +14 -0
- package/lib/storage.d.ts +11 -0
- package/lib/storage.js +54 -0
- package/package.json +113 -3
- package/pages/index/CommentReview.js +140 -0
- package/pages/index/CommentReview.tsx +264 -0
- package/pages/index/index.js +18 -0
- package/pages/index/index.tsx +18 -0
- package/pages/index/messages.js +57 -0
- package/pages/index/messages.ts +59 -0
- package/pages/index/rpc.js +31 -0
- package/pages/index/rpc.ts +72 -0
- package/plugin.js +33 -0
- package/schema.json +485 -0
- package/src/adapter.ts +45 -0
- package/src/client.ts +247 -0
- package/src/console.ts +161 -0
- package/src/drafts.ts +180 -0
- package/src/endpoint-directory.ts +64 -0
- package/src/endpoint.ts +504 -0
- package/src/index.ts +50 -0
- package/src/juejin-runtime-state.ts +11 -0
- package/src/login-text.ts +28 -0
- package/src/protocol.ts +377 -0
- package/src/review.ts +149 -0
- package/src/self-profile.ts +20 -0
- package/src/storage.ts +52 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { endpointSpec, type Endpoint, type EndpointSpecification } from 'zhin.js/adapter';
|
|
2
|
+
import type { CapabilityId } from 'zhin.js';
|
|
3
|
+
import { JuejinClient, type JuejinFetch } from './client.js';
|
|
4
|
+
import { JuejinDraftQueue, type JuejinReplyDraft } from './drafts.js';
|
|
5
|
+
import { type ResolvedJuejinConfig } from './protocol.js';
|
|
6
|
+
export type JuejinReviewErrorCode = 'not_found' | 'busy' | 'send_disabled' | 'circuit_open' | 'no_cookie' | 'rate_limited' | 'invalid_message' | 'unknown_outcome' | 'platform_error';
|
|
7
|
+
/** 审批 / 发送草稿失败。草稿除 `not_found` 外都会保留在队列里。 */
|
|
8
|
+
export declare class JuejinReviewError extends Error {
|
|
9
|
+
readonly code: JuejinReviewErrorCode;
|
|
10
|
+
readonly retryAfterMs?: number | undefined;
|
|
11
|
+
constructor(code: JuejinReviewErrorCode, message: string, retryAfterMs?: number | undefined);
|
|
12
|
+
}
|
|
13
|
+
export interface JuejinSentReply {
|
|
14
|
+
readonly draft: JuejinReplyDraft;
|
|
15
|
+
/** 平台返回的新回复 id;平台没给时省略。 */
|
|
16
|
+
readonly replyId?: string;
|
|
17
|
+
}
|
|
18
|
+
/** 登录态:none=没有 Cookie;ok=Cookie 有效;expired=Cookie 失效需要更新。 */
|
|
19
|
+
export type JuejinLoginState = 'none' | 'ok' | 'expired';
|
|
20
|
+
export interface JuejinLoginStatus {
|
|
21
|
+
readonly state: JuejinLoginState;
|
|
22
|
+
readonly userId?: string;
|
|
23
|
+
readonly userName?: string;
|
|
24
|
+
}
|
|
25
|
+
/** Console / 状态用的白名单文章视图(不含 Cookie)。 */
|
|
26
|
+
export interface JuejinArticleView {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly url: string;
|
|
29
|
+
}
|
|
30
|
+
/** 主动提醒的事件;文案由插件层按语言渲染。 */
|
|
31
|
+
export type JuejinNotice = {
|
|
32
|
+
readonly kind: 'draft';
|
|
33
|
+
readonly draft: JuejinReplyDraft;
|
|
34
|
+
} | {
|
|
35
|
+
readonly kind: 'login_expired';
|
|
36
|
+
};
|
|
37
|
+
export interface JuejinEndpointOptions {
|
|
38
|
+
readonly id: CapabilityId;
|
|
39
|
+
readonly signal?: AbortSignal;
|
|
40
|
+
readonly config: ResolvedJuejinConfig;
|
|
41
|
+
readonly fetch?: JuejinFetch;
|
|
42
|
+
/** 轮询抖动随机源(测试注入)。 */
|
|
43
|
+
readonly random?: () => number;
|
|
44
|
+
/** 关闭自动定时轮询(测试中手动调用 pollOnce)。 */
|
|
45
|
+
readonly autoPoll?: boolean;
|
|
46
|
+
/** 时钟(测试注入)。 */
|
|
47
|
+
readonly now?: () => number;
|
|
48
|
+
/** 本地状态目录(草稿)。省略 = 只存内存(测试用);adapter 默认传 `config.dataDir`。 */
|
|
49
|
+
readonly stateDir?: string;
|
|
50
|
+
/** 新草稿 / 登录失效时的主动提醒(失败只记日志,不影响草稿)。 */
|
|
51
|
+
readonly notify?: (notice: JuejinNotice) => void | Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
export declare class JuejinEndpoint extends JuejinClient implements Endpoint {
|
|
54
|
+
#private;
|
|
55
|
+
readonly [endpointSpec]: EndpointSpecification;
|
|
56
|
+
readonly drafts: JuejinDraftQueue;
|
|
57
|
+
constructor(options: JuejinEndpointOptions);
|
|
58
|
+
/** 当前登录态(不含 Cookie 本身)。 */
|
|
59
|
+
get login(): JuejinLoginStatus;
|
|
60
|
+
/** 草稿是否落盘(配置了数据目录)。 */
|
|
61
|
+
get persistent(): boolean;
|
|
62
|
+
/** 配置里的白名单文章。 */
|
|
63
|
+
get articles(): readonly JuejinArticleView[];
|
|
64
|
+
get sendMode(): ResolvedJuejinConfig['sendMode'];
|
|
65
|
+
get localId(): string;
|
|
66
|
+
/** 熔断原因;非空时轮询已停止,需要人工处理后重启。 */
|
|
67
|
+
get circuitBreaker(): Readonly<{
|
|
68
|
+
code: number;
|
|
69
|
+
message: string;
|
|
70
|
+
}> | undefined;
|
|
71
|
+
/** 重新检查 Cookie(Console「重新检查登录」)。不会切换账号:新 uid 与已识别的不一致时拒绝。 */
|
|
72
|
+
recheckLogin(): Promise<JuejinLoginStatus>;
|
|
73
|
+
/** 轮询一次全部白名单文章。每篇首轮只记录基线,不入站。 */
|
|
74
|
+
pollOnce(): Promise<void>;
|
|
75
|
+
/** 审批前修改草稿正文。 */
|
|
76
|
+
editDraft(id: string, message: string): JuejinReplyDraft;
|
|
77
|
+
/** 丢弃草稿(不发送)。 */
|
|
78
|
+
discardDraft(id: string): boolean;
|
|
79
|
+
/**
|
|
80
|
+
* 人工审批后真正发出草稿(`POST interact_api/v1/reply/publish`)。成功后草稿出队。
|
|
81
|
+
*
|
|
82
|
+
* 保护:sendMode=off / 已熔断 / 无有效 Cookie / 发送中 / 距上次发送不足 `minSendIntervalSec` 都直接拒绝,
|
|
83
|
+
* 不排队、不自动重试。风控会熔断整个 endpoint(同时停止轮询);结果未知时草稿保留并标记「可能已发出」。
|
|
84
|
+
*/
|
|
85
|
+
approveDraft(id: string): Promise<JuejinSentReply>;
|
|
86
|
+
}
|
package/lib/endpoint.js
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JuejinEndpoint — 白名单文章评论轮询 + 先审再发(Experimental)。
|
|
3
|
+
*
|
|
4
|
+
* - 入站:轮询 `comment/list`(最新一页),首轮只建基线;新评论按「评论楼」归 conversation 后 emit `message.receive`。
|
|
5
|
+
* 同一评论 / 回复 id 只入站一次;自己(登录账号 uid 或平台标记的文章作者)发的不入站,防自回环。
|
|
6
|
+
* - 出站:`send()` 永远只把回复放进草稿队列(review),不会调用平台写接口。
|
|
7
|
+
* - 审批:`approveDraft()` 由人触发(Console「掘金评论审批」页或 `juejin-draft send <id>`),才会调用 `reply/publish` 真正发出。
|
|
8
|
+
* - 风控(HTTP 403/429、非 JSON、`err_msg` 含频繁 / 验证码等)立即熔断,停止轮询与发送,不自动重试。
|
|
9
|
+
*/
|
|
10
|
+
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
11
|
+
import { createEndpointSpecification, endpointSpec } from 'zhin.js/adapter';
|
|
12
|
+
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
13
|
+
import { JuejinApiError, JuejinClient } from './client.js';
|
|
14
|
+
import { createJuejinSelfProfile } from './self-profile.js';
|
|
15
|
+
import { JuejinDraftQueue } from './drafts.js';
|
|
16
|
+
import { JuejinStateFiles } from './storage.js';
|
|
17
|
+
import { articleUrl, flattenComments, formatOutboundText, juejinThreadConversation, outboundReplyTarget, parseThreadConversationId, validateReplyText, } from './protocol.js';
|
|
18
|
+
const SEEN_CAPACITY = 5_000;
|
|
19
|
+
const CONTEXT_CAPACITY = 500;
|
|
20
|
+
const CONTEXT_EXCERPT = 120;
|
|
21
|
+
/** 审批 / 发送草稿失败。草稿除 `not_found` 外都会保留在队列里。 */
|
|
22
|
+
export class JuejinReviewError extends Error {
|
|
23
|
+
code;
|
|
24
|
+
retryAfterMs;
|
|
25
|
+
constructor(code, message, retryAfterMs) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.retryAfterMs = retryAfterMs;
|
|
29
|
+
this.name = 'JuejinReviewError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class JuejinEndpoint extends JuejinClient {
|
|
33
|
+
[endpointSpec];
|
|
34
|
+
drafts;
|
|
35
|
+
#files;
|
|
36
|
+
#options;
|
|
37
|
+
#logger;
|
|
38
|
+
#baselined = new Set();
|
|
39
|
+
#seen = new Set();
|
|
40
|
+
/** 评论 / 回复 id → 摘要(作者 + 原文 + 作者 uid),用于草稿审批时展示被回复的原文。 */
|
|
41
|
+
#context = new Map();
|
|
42
|
+
#lastSentAt = 0;
|
|
43
|
+
#selfUid;
|
|
44
|
+
#timer;
|
|
45
|
+
#state = 'idle';
|
|
46
|
+
#open = false;
|
|
47
|
+
#started = false;
|
|
48
|
+
#tripped;
|
|
49
|
+
#login = Object.freeze({ state: 'none' });
|
|
50
|
+
constructor(options) {
|
|
51
|
+
super(options.config, options.fetch ?? globalThis.fetch, options.signal);
|
|
52
|
+
this.#options = options;
|
|
53
|
+
const readTransportState = () => this.#transportState;
|
|
54
|
+
this[endpointSpec] = createEndpointSpecification(this, {
|
|
55
|
+
name: options.config.id,
|
|
56
|
+
start: () => this.#start(), open: () => this.#openEndpoint(),
|
|
57
|
+
close: () => this.#close(), stop: () => this.#stop(),
|
|
58
|
+
send: request => this.#send(request),
|
|
59
|
+
profile: createJuejinSelfProfile({ client: this, reportAccount: id => this[endpointSpec].reportSelfUserId(id) }),
|
|
60
|
+
get transportState() { return readTransportState(); },
|
|
61
|
+
});
|
|
62
|
+
this.#logger = getAdapterLogger('juejin', options.config.id);
|
|
63
|
+
this.#files = options.stateDir ? new JuejinStateFiles(options.stateDir, options.config.stateDirectoryId ?? options.config.id) : undefined;
|
|
64
|
+
this.drafts = new JuejinDraftQueue({
|
|
65
|
+
...(this.#files ? { file: this.#files.draftsFile } : {}),
|
|
66
|
+
onPersistError: (error) => this.#logger.warn(formatCompact({
|
|
67
|
+
op: 'juejin_draft_persist_failed', error: error instanceof Error ? error.message : String(error),
|
|
68
|
+
})),
|
|
69
|
+
});
|
|
70
|
+
if (this.drafts.size > 0) {
|
|
71
|
+
this.#logger.info(formatCompact({ op: 'juejin_drafts_restored', count: this.drafts.size }));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** 当前登录态(不含 Cookie 本身)。 */
|
|
75
|
+
get login() {
|
|
76
|
+
return this.#login;
|
|
77
|
+
}
|
|
78
|
+
/** 草稿是否落盘(配置了数据目录)。 */
|
|
79
|
+
get persistent() {
|
|
80
|
+
return this.#files !== undefined;
|
|
81
|
+
}
|
|
82
|
+
/** 配置里的白名单文章。 */
|
|
83
|
+
get articles() {
|
|
84
|
+
return Object.freeze(this.#options.config.articles.map(id => Object.freeze({ id, url: articleUrl(id) })));
|
|
85
|
+
}
|
|
86
|
+
get sendMode() {
|
|
87
|
+
return this.#options.config.sendMode;
|
|
88
|
+
}
|
|
89
|
+
get localId() {
|
|
90
|
+
return this.#options.config.id;
|
|
91
|
+
}
|
|
92
|
+
get #transportState() {
|
|
93
|
+
return this.#state;
|
|
94
|
+
}
|
|
95
|
+
/** 熔断原因;非空时轮询已停止,需要人工处理后重启。 */
|
|
96
|
+
get circuitBreaker() {
|
|
97
|
+
return this.#tripped;
|
|
98
|
+
}
|
|
99
|
+
get #now() {
|
|
100
|
+
return (this.#options.now ?? Date.now)();
|
|
101
|
+
}
|
|
102
|
+
async #start() {
|
|
103
|
+
if (this.#started)
|
|
104
|
+
return;
|
|
105
|
+
this.#started = true;
|
|
106
|
+
this.#state = 'connecting';
|
|
107
|
+
try {
|
|
108
|
+
await this.#resolveLogin();
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
this.#handleError('start', error);
|
|
112
|
+
if (this.#tripped)
|
|
113
|
+
return;
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
this[endpointSpec].reportSelfUserId(this.#selfUid);
|
|
117
|
+
if (this.#options.config.articles.length === 0) {
|
|
118
|
+
this.#logger.warn(formatCompact({ op: 'juejin_no_articles', hint: '未配置 articles 白名单,不会轮询' }));
|
|
119
|
+
}
|
|
120
|
+
this.#state = 'open';
|
|
121
|
+
if (this.#options.autoPoll !== false && this.#options.config.articles.length > 0)
|
|
122
|
+
this.#schedule(0);
|
|
123
|
+
}
|
|
124
|
+
#openEndpoint() {
|
|
125
|
+
this.#open = true;
|
|
126
|
+
}
|
|
127
|
+
#close() {
|
|
128
|
+
this.#open = false;
|
|
129
|
+
}
|
|
130
|
+
#stop() {
|
|
131
|
+
this.#open = false;
|
|
132
|
+
if (this.#timer)
|
|
133
|
+
clearTimeout(this.#timer);
|
|
134
|
+
this.#timer = undefined;
|
|
135
|
+
this.#started = false;
|
|
136
|
+
this.#state = 'stopped';
|
|
137
|
+
}
|
|
138
|
+
/** 用配置 / 环境变量里的 Cookie 识别账号;失效时登录态为 expired,只读轮询照常。 */
|
|
139
|
+
async #resolveLogin() {
|
|
140
|
+
const cookie = this.#options.config.cookie;
|
|
141
|
+
this.setCookie(cookie);
|
|
142
|
+
if (!cookie) {
|
|
143
|
+
this.#selfUid = undefined;
|
|
144
|
+
this.#login = Object.freeze({ state: 'none' });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const self = await this.getSelf();
|
|
148
|
+
if (self) {
|
|
149
|
+
this.#selfUid = self.userId;
|
|
150
|
+
this.#login = Object.freeze({ state: 'ok', userId: self.userId, ...(self.userName ? { userName: self.userName } : {}) });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
this.#selfUid = undefined;
|
|
154
|
+
this.#login = Object.freeze({ state: 'expired' });
|
|
155
|
+
this.#logger.warn(formatCompact({ op: 'juejin_login_invalid', hint: '配置里的掘金 Cookie 已失效,请更新环境变量后重启' }));
|
|
156
|
+
}
|
|
157
|
+
/** 重新检查 Cookie(Console「重新检查登录」)。不会切换账号:新 uid 与已识别的不一致时拒绝。 */
|
|
158
|
+
async recheckLogin() {
|
|
159
|
+
const previous = this.#selfUid;
|
|
160
|
+
const before = this.#login.state;
|
|
161
|
+
try {
|
|
162
|
+
await this.#resolveLogin();
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
this.#handleError('login_check', error);
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
if (previous && this.#selfUid && previous !== this.#selfUid) {
|
|
169
|
+
this.#tripped = { code: -3, message: 'Juejin account changed; restart the endpoint after updating the config' };
|
|
170
|
+
this.#halt('login_check');
|
|
171
|
+
}
|
|
172
|
+
if (this.#login.state === 'expired' && before !== 'expired')
|
|
173
|
+
this.#emitNotice({ kind: 'login_expired' });
|
|
174
|
+
return this.#login;
|
|
175
|
+
}
|
|
176
|
+
/** Cookie 被平台判定失效(err_no 403):登录态置为 expired,并提醒一次。 */
|
|
177
|
+
#markLoginExpired(stage) {
|
|
178
|
+
if (this.#login.state === 'expired')
|
|
179
|
+
return;
|
|
180
|
+
this.#login = Object.freeze({ state: 'expired' });
|
|
181
|
+
this.#logger.warn(formatCompact({ op: 'juejin_login_invalid', stage, hint: 'Cookie 已失效,请更新 JUEJIN_COOKIE 后重启' }));
|
|
182
|
+
this.#emitNotice({ kind: 'login_expired' });
|
|
183
|
+
}
|
|
184
|
+
#emitNotice(notice) {
|
|
185
|
+
const notify = this.#options.notify;
|
|
186
|
+
if (!notify)
|
|
187
|
+
return;
|
|
188
|
+
void Promise.resolve().then(() => notify(notice)).catch((error) => {
|
|
189
|
+
this.#logger.warn(formatCompact({
|
|
190
|
+
op: 'juejin_notify_failed', kind: notice.kind,
|
|
191
|
+
error: error instanceof Error ? error.message : String(error),
|
|
192
|
+
}));
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
#schedule(delayMs) {
|
|
196
|
+
if (this.#tripped || !this.#started)
|
|
197
|
+
return;
|
|
198
|
+
this.#timer = setTimeout(() => {
|
|
199
|
+
void this.pollOnce().finally(() => {
|
|
200
|
+
const base = this.#options.config.pollIntervalMs;
|
|
201
|
+
const jitter = Math.floor(base * 0.2 * (this.#options.random ?? Math.random)());
|
|
202
|
+
this.#schedule(base + jitter);
|
|
203
|
+
});
|
|
204
|
+
}, delayMs);
|
|
205
|
+
this.#timer.unref?.();
|
|
206
|
+
}
|
|
207
|
+
/** 轮询一次全部白名单文章。每篇首轮只记录基线,不入站。 */
|
|
208
|
+
async pollOnce() {
|
|
209
|
+
for (const articleId of this.#options.config.articles) {
|
|
210
|
+
if (this.#tripped)
|
|
211
|
+
return;
|
|
212
|
+
try {
|
|
213
|
+
const page = await this.listLatestComments(articleId);
|
|
214
|
+
await this[endpointSpec].events.run(async () => {
|
|
215
|
+
const comments = flattenComments(articleId, page.comments).sort((a, b) => a.ctime - b.ctime);
|
|
216
|
+
if (!this.#baselined.has(articleId)) {
|
|
217
|
+
for (const comment of comments) {
|
|
218
|
+
this.#markSeen(comment.id);
|
|
219
|
+
this.#remember(comment);
|
|
220
|
+
}
|
|
221
|
+
this.#baselined.add(articleId);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
for (const comment of comments) {
|
|
225
|
+
if (this.#seen.has(comment.id))
|
|
226
|
+
continue;
|
|
227
|
+
this.#markSeen(comment.id);
|
|
228
|
+
await this.#admit(comment);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
this.#handleError('poll', error, `article:${articleId}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
#markSeen(id) {
|
|
238
|
+
this.#seen.add(id);
|
|
239
|
+
if (this.#seen.size > SEEN_CAPACITY) {
|
|
240
|
+
const oldest = this.#seen.values().next().value;
|
|
241
|
+
if (oldest !== undefined)
|
|
242
|
+
this.#seen.delete(oldest);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
#remember(comment) {
|
|
246
|
+
const text = comment.text.replace(/\s+/g, ' ').trim();
|
|
247
|
+
const excerpt = [...text].length > CONTEXT_EXCERPT ? `${[...text].slice(0, CONTEXT_EXCERPT).join('')}…` : text;
|
|
248
|
+
this.#context.set(comment.id, Object.freeze({ author: comment.userName ?? comment.userId, text: excerpt, rootId: comment.rootId }));
|
|
249
|
+
if (this.#context.size > CONTEXT_CAPACITY) {
|
|
250
|
+
const oldest = this.#context.keys().next().value;
|
|
251
|
+
if (oldest !== undefined)
|
|
252
|
+
this.#context.delete(oldest);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** 自己发的:登录账号 uid,或平台标记的文章作者(白名单只放自己的文章)。 */
|
|
256
|
+
#isSelf(comment) {
|
|
257
|
+
return comment.isAuthor || (this.#selfUid !== undefined && comment.userId === this.#selfUid);
|
|
258
|
+
}
|
|
259
|
+
async #admit(comment) {
|
|
260
|
+
if (!this.#open && !this.#started)
|
|
261
|
+
return;
|
|
262
|
+
this.#remember(comment);
|
|
263
|
+
if (this.#isSelf(comment))
|
|
264
|
+
return; // 防自回环
|
|
265
|
+
const conversation = { ...juejinThreadConversation(String(this.#options.id), comment.articleId, comment.rootId), endpoint: this[endpointSpec].identity };
|
|
266
|
+
void this[endpointSpec].events.platform(comment.kind, comment).catch(() => undefined);
|
|
267
|
+
await this[endpointSpec].events.message({
|
|
268
|
+
event: comment,
|
|
269
|
+
platformSentAt: comment.ctime * 1000,
|
|
270
|
+
conversation,
|
|
271
|
+
message: { conversation, id: comment.id },
|
|
272
|
+
content: comment.text,
|
|
273
|
+
sender: { id: comment.userId, name: comment.userName ?? comment.userId },
|
|
274
|
+
endpointId: this.#options.config.id,
|
|
275
|
+
...(comment.parentId !== comment.id ? { replyTo: { id: comment.parentId } } : {}),
|
|
276
|
+
metadata: Object.freeze({
|
|
277
|
+
resource: Object.freeze({ type: 'article', articleId: comment.articleId, url: articleUrl(comment.articleId) }),
|
|
278
|
+
rootId: comment.rootId,
|
|
279
|
+
parentId: comment.parentId,
|
|
280
|
+
kind: comment.kind,
|
|
281
|
+
createdAt: comment.ctime * 1000,
|
|
282
|
+
likeCount: comment.diggCount,
|
|
283
|
+
isAuthor: comment.isAuthor,
|
|
284
|
+
}),
|
|
285
|
+
}).catch((error) => {
|
|
286
|
+
this.#logger.warn(formatCompact({
|
|
287
|
+
op: 'juejin_gateway_receive_failed',
|
|
288
|
+
target: conversation.id,
|
|
289
|
+
error: error instanceof Error ? error.message : String(error),
|
|
290
|
+
}));
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
#halt(stage, target) {
|
|
294
|
+
if (this.#timer)
|
|
295
|
+
clearTimeout(this.#timer);
|
|
296
|
+
this.#timer = undefined;
|
|
297
|
+
this.#state = 'closed';
|
|
298
|
+
this.#logger.error(formatCompact({
|
|
299
|
+
op: 'juejin_circuit_open', stage, target, code: this.#tripped?.code,
|
|
300
|
+
hint: '触发掘金风控或账号异常,已停止轮询与发送;请人工确认账号状态后重启',
|
|
301
|
+
}));
|
|
302
|
+
}
|
|
303
|
+
#handleError(op, error, target) {
|
|
304
|
+
if (error instanceof JuejinApiError && error.loginRequired && op === 'send') {
|
|
305
|
+
this.#markLoginExpired(op);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (error instanceof JuejinApiError && error.risk) {
|
|
309
|
+
this.#tripped = { code: error.code, message: error.message };
|
|
310
|
+
this.#halt(op, target);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
this.#logger.warn(formatCompact({
|
|
314
|
+
op: `juejin_${op}_failed`, target,
|
|
315
|
+
error: error instanceof Error ? error.message : String(error),
|
|
316
|
+
}));
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* 出站 = 回复评论楼。**永远**只进草稿队列,返回 not_sent(approval_pending),不调用平台写接口;
|
|
320
|
+
* 真正发出必须由人调用 {@link approveDraft}。
|
|
321
|
+
*/
|
|
322
|
+
async #send({ conversation, payload }) {
|
|
323
|
+
const thread = parseThreadConversationId(conversation.id);
|
|
324
|
+
if (!thread) {
|
|
325
|
+
throw new EndpointDeliveryError('invalid_recipient', `Juejin conversation must be article:<articleId>:<rootCommentId>, got "${conversation.id}"`, 'not_sent');
|
|
326
|
+
}
|
|
327
|
+
if (!this.#options.config.articles.includes(thread.articleId)) {
|
|
328
|
+
throw new EndpointDeliveryError('invalid_recipient', `Juejin article ${thread.articleId} is not in the articles whitelist`, 'not_sent');
|
|
329
|
+
}
|
|
330
|
+
const message = formatOutboundText(payload);
|
|
331
|
+
const invalid = validateReplyText(message);
|
|
332
|
+
if (invalid)
|
|
333
|
+
throw new EndpointDeliveryError('invalid_payload', invalid, 'not_sent');
|
|
334
|
+
if (this.#options.config.sendMode === 'off') {
|
|
335
|
+
throw new EndpointDeliveryError('send_disabled', 'Juejin sendMode is off', 'not_sent');
|
|
336
|
+
}
|
|
337
|
+
// 只接受同一评论楼里已知的回复目标;未知 / 跨楼的 reply 段退回到楼根,绝不回到别的楼。
|
|
338
|
+
const requested = outboundReplyTarget(payload);
|
|
339
|
+
const known = requested ? this.#context.get(requested) : undefined;
|
|
340
|
+
const parent = requested && (requested === thread.root || known?.rootId === thread.root) ? requested : thread.root;
|
|
341
|
+
const context = this.#context.get(parent) ?? this.#context.get(thread.root);
|
|
342
|
+
const draft = this.drafts.add({
|
|
343
|
+
endpointId: this.#options.config.id,
|
|
344
|
+
target: conversation.id,
|
|
345
|
+
articleId: thread.articleId,
|
|
346
|
+
root: thread.root,
|
|
347
|
+
parent,
|
|
348
|
+
message,
|
|
349
|
+
...(context ? { context: { author: context.author, text: context.text } } : {}),
|
|
350
|
+
}, this.#now);
|
|
351
|
+
this.#logger.info(formatCompact({ op: 'juejin_draft_queued', draft: draft.id, target: conversation.id }));
|
|
352
|
+
this.#emitNotice({ kind: 'draft', draft });
|
|
353
|
+
throw new EndpointDeliveryError('approval_pending', `Reply queued as draft ${draft.id}; master approval is required before posting`, 'not_sent');
|
|
354
|
+
}
|
|
355
|
+
/** 审批前修改草稿正文。 */
|
|
356
|
+
editDraft(id, message) {
|
|
357
|
+
const draft = this.drafts.get(id);
|
|
358
|
+
if (!draft)
|
|
359
|
+
throw new JuejinReviewError('not_found', `Draft ${id} not found`);
|
|
360
|
+
if (draft.status === 'sending')
|
|
361
|
+
throw new JuejinReviewError('busy', `Draft ${id} is being sent`);
|
|
362
|
+
const text = message.trim();
|
|
363
|
+
const invalid = validateReplyText(text);
|
|
364
|
+
if (invalid)
|
|
365
|
+
throw new JuejinReviewError('invalid_message', invalid);
|
|
366
|
+
return this.drafts.update(id, { message: text }, this.#now);
|
|
367
|
+
}
|
|
368
|
+
/** 丢弃草稿(不发送)。 */
|
|
369
|
+
discardDraft(id) {
|
|
370
|
+
const draft = this.drafts.get(id);
|
|
371
|
+
if (draft?.status === 'sending')
|
|
372
|
+
throw new JuejinReviewError('busy', `Draft ${id} is being sent`);
|
|
373
|
+
const removed = this.drafts.discard(id);
|
|
374
|
+
if (removed)
|
|
375
|
+
this.#logger.info(formatCompact({ op: 'juejin_draft_discarded', draft: id }));
|
|
376
|
+
return removed;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* 人工审批后真正发出草稿(`POST interact_api/v1/reply/publish`)。成功后草稿出队。
|
|
380
|
+
*
|
|
381
|
+
* 保护:sendMode=off / 已熔断 / 无有效 Cookie / 发送中 / 距上次发送不足 `minSendIntervalSec` 都直接拒绝,
|
|
382
|
+
* 不排队、不自动重试。风控会熔断整个 endpoint(同时停止轮询);结果未知时草稿保留并标记「可能已发出」。
|
|
383
|
+
*/
|
|
384
|
+
async approveDraft(id) {
|
|
385
|
+
const draft = this.drafts.get(id);
|
|
386
|
+
if (!draft)
|
|
387
|
+
throw new JuejinReviewError('not_found', `Draft ${id} not found`);
|
|
388
|
+
if (draft.status === 'sending')
|
|
389
|
+
throw new JuejinReviewError('busy', `Draft ${id} is already being sent`);
|
|
390
|
+
if (this.#options.config.sendMode === 'off') {
|
|
391
|
+
throw new JuejinReviewError('send_disabled', 'Juejin sendMode is off');
|
|
392
|
+
}
|
|
393
|
+
if (this.#tripped) {
|
|
394
|
+
throw new JuejinReviewError('circuit_open', `Circuit breaker open (${this.#tripped.code}); check the account on juejin.cn and restart`);
|
|
395
|
+
}
|
|
396
|
+
if (!this.hasCookie || this.#login.state !== 'ok') {
|
|
397
|
+
throw new JuejinReviewError('no_cookie', 'A valid Juejin login cookie is required to post replies');
|
|
398
|
+
}
|
|
399
|
+
if (!this.#options.config.articles.includes(draft.articleId)) {
|
|
400
|
+
throw new JuejinReviewError('send_disabled', `Juejin article ${draft.articleId} is no longer in the articles whitelist`);
|
|
401
|
+
}
|
|
402
|
+
const invalid = validateReplyText(draft.message);
|
|
403
|
+
if (invalid)
|
|
404
|
+
throw new JuejinReviewError('invalid_message', invalid);
|
|
405
|
+
const wait = this.#lastSentAt + this.#options.config.minSendIntervalMs - this.#now;
|
|
406
|
+
if (this.#lastSentAt > 0 && wait > 0) {
|
|
407
|
+
throw new JuejinReviewError('rate_limited', `Wait ${Math.ceil(wait / 1000)}s before sending another reply`, wait);
|
|
408
|
+
}
|
|
409
|
+
this.drafts.update(id, { status: 'sending' }, this.#now);
|
|
410
|
+
this.#lastSentAt = this.#now;
|
|
411
|
+
try {
|
|
412
|
+
const replyId = await this.replyComment({
|
|
413
|
+
articleId: draft.articleId,
|
|
414
|
+
root: draft.root,
|
|
415
|
+
parent: draft.parent,
|
|
416
|
+
message: draft.message,
|
|
417
|
+
});
|
|
418
|
+
if (replyId)
|
|
419
|
+
this.#markSeen(replyId);
|
|
420
|
+
this.drafts.discard(id);
|
|
421
|
+
this.#logger.info(formatCompact({ op: 'juejin_draft_sent', draft: id, target: draft.target, reply: replyId }));
|
|
422
|
+
return Object.freeze({ draft, ...(replyId ? { replyId } : {}) });
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
const ambiguous = error instanceof JuejinApiError && error.ambiguous;
|
|
426
|
+
this.drafts.update(id, { status: 'pending', ...(ambiguous ? { interrupted: true } : {}) }, this.#now);
|
|
427
|
+
this.#handleError('send', error);
|
|
428
|
+
if (error instanceof JuejinApiError && error.risk) {
|
|
429
|
+
throw new JuejinReviewError('circuit_open', `Juejin risk control (${error.code}); endpoint stopped, draft kept`);
|
|
430
|
+
}
|
|
431
|
+
if (ambiguous) {
|
|
432
|
+
throw new JuejinReviewError('unknown_outcome', 'Juejin did not confirm the reply; it may have been posted. Check the article before sending again');
|
|
433
|
+
}
|
|
434
|
+
if (error instanceof JuejinApiError && error.loginRequired) {
|
|
435
|
+
throw new JuejinReviewError('no_cookie', 'Juejin login expired; update the cookie, draft kept');
|
|
436
|
+
}
|
|
437
|
+
throw new JuejinReviewError('platform_error', error instanceof Error ? error.message : String(error));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from './protocol.js';
|
|
2
|
+
export { JuejinApiError, JuejinClient, type JuejinClientEventMap, type JuejinCommentPage, type JuejinFetch, type JuejinReplyInput, type JuejinSelf, } from './client.js';
|
|
3
|
+
export { JuejinDraftQueue, type JuejinDraftQueueOptions, type JuejinDraftStatus, type JuejinReplyDraft, } from './drafts.js';
|
|
4
|
+
export { JuejinStateFiles } from './storage.js';
|
|
5
|
+
export { JUEJIN_LOGIN_PERMISSION, JUEJIN_READ_PERMISSION, JUEJIN_REVIEW_PERMISSION, createJuejinConsoleRpc, } from './console.js';
|
|
6
|
+
export { loginStatusLine, loginStatusText } from './login-text.js';
|
|
7
|
+
export { JuejinEndpoint, JuejinReviewError, type JuejinArticleView, type JuejinEndpointOptions, type JuejinLoginState, type JuejinLoginStatus, type JuejinNotice, type JuejinReviewErrorCode, type JuejinSentReply, } from './endpoint.js';
|
|
8
|
+
export { juejinNotifierToken, type JuejinNotifier } from './juejin-runtime-state.js';
|
|
9
|
+
export { approveDraftText, draftEditUsage, type DraftPresentation, discardDraftText, editDraftText, formatDraft, formatNotice, listDraftsText, showDraftText, } from './review.js';
|
|
10
|
+
export { JuejinAdapter } from './adapter.js';
|
|
11
|
+
export { juejinEndpoints, type JuejinDirectoryContext, type JuejinEndpointDirectory, type JuejinEndpointView } from './endpoint-directory.js';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from './protocol.js';
|
|
2
|
+
export { JuejinApiError, JuejinClient, } from './client.js';
|
|
3
|
+
export { JuejinDraftQueue, } from './drafts.js';
|
|
4
|
+
export { JuejinStateFiles } from './storage.js';
|
|
5
|
+
export { JUEJIN_LOGIN_PERMISSION, JUEJIN_READ_PERMISSION, JUEJIN_REVIEW_PERMISSION, createJuejinConsoleRpc, } from './console.js';
|
|
6
|
+
export { loginStatusLine, loginStatusText } from './login-text.js';
|
|
7
|
+
export { JuejinEndpoint, JuejinReviewError, } from './endpoint.js';
|
|
8
|
+
export { juejinNotifierToken } from './juejin-runtime-state.js';
|
|
9
|
+
export { approveDraftText, draftEditUsage, discardDraftText, editDraftText, formatDraft, formatNotice, listDraftsText, showDraftText, } from './review.js';
|
|
10
|
+
export { JuejinAdapter } from './adapter.js';
|
|
11
|
+
export { juejinEndpoints } from './endpoint-directory.js';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ResolvedJuejinNotifyTarget } from './protocol.js';
|
|
2
|
+
/** 返回 false 表示当前没有可用的出站通道(只记日志)。 */
|
|
3
|
+
export type JuejinNotifier = (target: ResolvedJuejinNotifyTarget, text: string) => Promise<boolean>;
|
|
4
|
+
export declare const juejinNotifierToken: import("zhin.js").Token<JuejinNotifier>;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** Domain notifier only; account ownership and review directory are projected from the Adapter. */
|
|
2
|
+
import { createToken } from 'zhin.js';
|
|
3
|
+
export const juejinNotifierToken = createToken('zhin.juejin-comment.notifier', 'Pushes Juejin draft / login notices to a configured chat conversation via OutboundHost');
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** `juejin-login status` 命令的文案;Cookie 只来自配置 / 环境变量,这里不处理登录。 */
|
|
2
|
+
import { type Locale } from 'zhin.js/i18n';
|
|
3
|
+
import type { JuejinEndpointDirectory, JuejinEndpointView } from './endpoint-directory.js';
|
|
4
|
+
export declare function loginStatusLine(endpoint: JuejinEndpointView, locale?: Locale): string;
|
|
5
|
+
export declare function loginStatusText(registry: JuejinEndpointDirectory, locale?: Locale): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** `juejin-login status` 命令的文案;Cookie 只来自配置 / 环境变量,这里不处理登录。 */
|
|
2
|
+
import { createMessages } from 'zhin.js/i18n';
|
|
3
|
+
const t = createMessages({
|
|
4
|
+
none: { 'zh-CN': '掘金适配器还没有启动', en: 'The Juejin adapter has not started.' },
|
|
5
|
+
ok: { 'zh-CN': '[{id}] 已登录:{name}(Cookie 来自配置 / 环境变量)', en: '[{id}] Signed in: {name} (cookie from config or environment)' },
|
|
6
|
+
expired: { 'zh-CN': '[{id}] 登录已失效,请更新 JUEJIN_COOKIE 后重启', en: '[{id}] Login expired; update JUEJIN_COOKIE and restart.' },
|
|
7
|
+
anonymous: { 'zh-CN': '[{id}] 未登录(只读轮询公开评论,无法发送)', en: '[{id}] Not signed in (public comments are read-only; sending is unavailable).' },
|
|
8
|
+
drafts: { 'zh-CN': '待审草稿 {count} 条', en: '{count} drafts awaiting review' },
|
|
9
|
+
circuit: { 'zh-CN': '已熔断({code}),轮询与发送已停止', en: 'Circuit open ({code}); polling and sending are stopped' },
|
|
10
|
+
});
|
|
11
|
+
export function loginStatusLine(endpoint, locale) {
|
|
12
|
+
const id = endpoint.localId;
|
|
13
|
+
const login = endpoint.login;
|
|
14
|
+
const base = login.state === 'ok'
|
|
15
|
+
? t(locale, 'ok', { id, name: login.userName ? `${login.userName} (uid ${login.userId})` : `uid ${login.userId}` })
|
|
16
|
+
: t(locale, login.state === 'expired' ? 'expired' : 'anonymous', { id });
|
|
17
|
+
const circuit = endpoint.circuitBreaker;
|
|
18
|
+
return [base, ...(circuit ? [t(locale, 'circuit', { code: circuit.code })] : []), t(locale, 'drafts', { count: endpoint.drafts.size })].join('\n');
|
|
19
|
+
}
|
|
20
|
+
export function loginStatusText(registry, locale) {
|
|
21
|
+
const endpoints = [...registry.values()];
|
|
22
|
+
if (endpoints.length === 0)
|
|
23
|
+
return t(locale, 'none');
|
|
24
|
+
return endpoints.map(endpoint => loginStatusLine(endpoint, locale)).join('\n\n');
|
|
25
|
+
}
|