@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
package/README.md
CHANGED
|
@@ -1,3 +1,146 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zhin.js/adapter-juejin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Experimental** — 基于掘金 **非官方 Web 接口**,随时可能因平台改版失效;不进 stable smoke,接口与配置可能不兼容变更。**真实发送(回复评论)的请求在纯 HTTP 下未经实测验证**,见下方「人工验证清单」。
|
|
4
|
+
|
|
5
|
+
账号由 `JuejinAdapter` 管理。Endpoint 直接继承 `JuejinEndpoint`;框架生命周期、规范入站与发送经 `endpointSpec` 和 AdapterIndex 接入。
|
|
6
|
+
|
|
7
|
+
Zhin.js 掘金评论适配器(Plugin Runtime):让 AI 辅助回复**你自己的掘金文章**下的评论。契约见 [ADR 0035:评论型平台适配器契约](https://github.com/zhinjs/zhin/blob/main/docs/adr/0035-comment-reply-adapter-contract.md),讨论见 [#971](https://github.com/zhinjs/zhin/issues/971)。
|
|
8
|
+
|
|
9
|
+
## 先读这几条
|
|
10
|
+
|
|
11
|
+
- **以你的账号身份操作**:掘金没有开放的评论读写 API,本适配器在发送时使用你的登录 Cookie。Cookie **只能经环境变量注入**(如 `cookie: ${JUEJIN_COOKIE}`),不会被写入数据目录。Cookie 等同于账号密码:**不要写进 `zhin.config.yml` 明文,不要提交到仓库,不要贴进日志或 issue**。不提供扫码登录。
|
|
12
|
+
- **默认先审再发**:`sendMode` 只有 `review`(默认)和 `off`。AI 写好的回复只进入草稿队列,**不会自动发到掘金**;只有你在 Console「掘金评论审批」页点「发送」,或 master 执行 `juejin-draft send <id>` 后才真正发出。不提供自动直发。
|
|
13
|
+
- **只回复评论,不发顶层评论**:出站只能回复白名单文章下已有的评论楼(或楼内回复),不会在文章下新开评论。
|
|
14
|
+
- **只管白名单文章**:只轮询 `articles` 里列出的文章(只填你自己的);留空就不轮询。不做全站搜索、不去别人的评论区;请遵守掘金用户协议,禁止用于营销、引流、刷屏。
|
|
15
|
+
- **风控即熔断**:遇到频繁 / 验证码 / 风控 / 非 JSON 响应等情况,endpoint 立即停止轮询与发送,不自动重试;人工确认账号状态后重启。
|
|
16
|
+
|
|
17
|
+
## 当前能力
|
|
18
|
+
|
|
19
|
+
| 能力 | 状态 |
|
|
20
|
+
|---|---|
|
|
21
|
+
| 文章 id / `juejin.cn/post/<id>` 链接解析 | ✅ 已实现 |
|
|
22
|
+
| 轮询白名单文章最新评论(`interact_api/v1/comment/list`,公开接口,不需要 Cookie) | ✅ 已实现(只读,实测) |
|
|
23
|
+
| 一条评论楼 = 一个会话(`article:<文章id>:<根评论id>`) | ✅ 已实现 |
|
|
24
|
+
| 首轮只建基线,不把历史评论灌给 AI;按评论 id 去重 | ✅ 已实现 |
|
|
25
|
+
| 过滤自己的评论(登录 uid,或接口标记的作者本人) | ✅ 已实现 |
|
|
26
|
+
| AI 回复进入草稿队列(`review`) | ✅ 已实现 |
|
|
27
|
+
| 草稿持久化(重启不丢,发送中断会提示先确认) | ✅ 已实现(`data/juejin/<账号>/drafts.json`,权限 600) |
|
|
28
|
+
| master 审批后真正发出(`juejin-draft send` → `interact_api/v1/reply/publish`) | ⚠️ 已实现但**未实测**(带发送间隔与风控熔断,结果不明时不当作「未发送」) |
|
|
29
|
+
| 审批前改写 / 丢弃草稿(`juejin-draft edit` / `drop`) | ✅ 已实现 |
|
|
30
|
+
| Cookie 失效识别(`err_no 403 must login`) | ✅ 已实现 |
|
|
31
|
+
| Console「掘金评论审批」页:登录状态、白名单文章、待审草稿的发送 / 改写 / 丢弃 | ✅ 已实现 |
|
|
32
|
+
| 新草稿 / 登录失效主动推送给你(`notify`) | ✅ 已实现(可选) |
|
|
33
|
+
| 扫码登录 | ❌ 不提供(未能安全验证) |
|
|
34
|
+
| 消息中心「评论我的」通知 | ❌ 不提供 |
|
|
35
|
+
|
|
36
|
+
## 前置条件
|
|
37
|
+
|
|
38
|
+
1. 一个你自己的掘金账号,以及要管理的文章 id(`https://juejin.cn/post/<id>` 里的数字,建议只填自己的)
|
|
39
|
+
2. 只读轮询不需要登录。要发送回复时:浏览器登录 juejin.cn 后,从开发者工具 → Network 里任意 `api.juejin.cn` 请求的 `Cookie` 请求头复制整行,放进 `.env`(已被 gitignore):`JUEJIN_COOKIE=...`
|
|
40
|
+
3. 数据目录 `data/` 不要提交到仓库
|
|
41
|
+
4. Cookie 会过期;失效时页面显示「登录已失效」、日志出现 `juejin_login_invalid`,更新环境变量后重启
|
|
42
|
+
|
|
43
|
+
必填字段(`endpoints[i]`):`id`。
|
|
44
|
+
|
|
45
|
+
## 最小配置
|
|
46
|
+
|
|
47
|
+
```yaml
|
|
48
|
+
configVersion: 2
|
|
49
|
+
plugins:
|
|
50
|
+
juejin: "@zhin.js/adapter-juejin"
|
|
51
|
+
juejin:
|
|
52
|
+
sendMode: review # 默认;off = 完全禁止出站。不支持 auto
|
|
53
|
+
pollIntervalSec: 60 # 最小 60
|
|
54
|
+
minSendIntervalSec: 30 # 审批后两次真实发送的最小间隔,最小 10
|
|
55
|
+
notify: # 可选:新草稿 / 登录失效时提醒你
|
|
56
|
+
adapter: icqq
|
|
57
|
+
endpoint: my-qq
|
|
58
|
+
id: "10001"
|
|
59
|
+
endpoints:
|
|
60
|
+
- id: my-juejin
|
|
61
|
+
cookie: ${JUEJIN_COOKIE} # 只用环境变量;不要写明文
|
|
62
|
+
articles:
|
|
63
|
+
- "7129482663794049032" # 一定加引号:文章 id 超过 JS 安全整数
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Console「掘金评论审批」页
|
|
67
|
+
|
|
68
|
+
装了 Console 时,导航里会出现「掘金评论审批」。页面上可以看登录状态(并「重新检查登录」)、看待审回复(原评论 + 拟回复)并点「发送」「修改」「丢弃」,以及看正在管理的文章(只读)。
|
|
69
|
+
|
|
70
|
+
| 权限 | 允许 |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `juejin:read` | 打开页面、看状态和草稿 |
|
|
73
|
+
| `juejin:review` | 发送 / 改写 / 丢弃草稿 |
|
|
74
|
+
| `juejin:login` | 重新检查登录 |
|
|
75
|
+
|
|
76
|
+
审批命令只认 master 角色(在你平时和机器人聊天的适配器配置里设置 `master`)。
|
|
77
|
+
|
|
78
|
+
## 会话与消息
|
|
79
|
+
|
|
80
|
+
- `conversation.kind = 'channel'`,`id = article:<文章id>:<根评论id>`,`parent = article:<文章id>`
|
|
81
|
+
- `message.id` = 评论 id 或回复 id;`sender` = 作者 uid / 昵称
|
|
82
|
+
- `metadata`:`resource`(`articleId` / `url`)、`rootId`、`parentId`、`createdAt`、`likeCount`、`isAuthor`
|
|
83
|
+
- 楼内回复带 `replyTo`
|
|
84
|
+
|
|
85
|
+
## 出站
|
|
86
|
+
|
|
87
|
+
`send({ conversation, payload })` 只接受评论楼会话,正文只保留纯文本(≤1000 字)。它**永远**只把回复放入 `endpoint.drafts`,并以 `EndpointDeliveryError('approval_pending', …, 'not_sent')` 告知调用方「未发送、待审批」。payload 里带 `reply` 段(且是同一楼里已知的回复 id)时回复那条楼内回复,否则回复楼主。
|
|
88
|
+
|
|
89
|
+
### 审批草稿
|
|
90
|
+
|
|
91
|
+
| 命令 | 作用 |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `juejin-draft list` | 列出待审草稿 |
|
|
94
|
+
| `juejin-draft show <id>` | 看一条草稿全文 |
|
|
95
|
+
| `juejin-draft edit <id> <新内容>` | 改写草稿,改完仍需 `send` |
|
|
96
|
+
| `juejin-draft send <id>` | 审批并发出,成功后返回文章链接 |
|
|
97
|
+
| `juejin-draft drop <id>` | 丢弃草稿,不发送 |
|
|
98
|
+
| `juejin-login status` | 查看登录状态与待审数量 |
|
|
99
|
+
|
|
100
|
+
发送保护:`sendMode: off`、已熔断、没有 Cookie 或登录失效、文章已不在白名单、距上次发送不足 `minSendIntervalSec` 时直接拒绝(草稿保留,不自动重试)。发送请求的响应无法解析(空响应、非 JSON、网络中断)时,草稿标记「可能已经发出」并保留,**不会自动重发**;请先到掘金确认。进程在发送中退出的草稿重启后同样标记。
|
|
101
|
+
|
|
102
|
+
程序里也可以直接调用 `endpoint.approveDraft(id)` / `editDraft(id, text)` / `discardDraft(id)` / `recheckLogin()`。
|
|
103
|
+
|
|
104
|
+
### 风控与熔断
|
|
105
|
+
|
|
106
|
+
掘金没有公开的错误码表。以下情况会熔断(停止轮询与发送,日志 `juejin_circuit_open`):
|
|
107
|
+
|
|
108
|
+
| 触发 | 说明 |
|
|
109
|
+
|---|---|
|
|
110
|
+
| HTTP 403 / 429 | 被拦截或限流 |
|
|
111
|
+
| 非 JSON 响应(内部 code `-2`) | 通常是验证页 / 风控页 |
|
|
112
|
+
| `err_msg` 含「频繁 / 过快 / 太快 / 风控 / 验证码 / 人机 / 安全验证」或 captcha / verify / too many / too frequent / rate limit | 平台限频或人机验证 |
|
|
113
|
+
| 登录账号 uid 变化(内部 code `-3`) | Cookie 换了人,需要重启 |
|
|
114
|
+
|
|
115
|
+
`err_no: 403`(`must login`)按登录失效处理,不熔断。
|
|
116
|
+
|
|
117
|
+
## 已知限制
|
|
118
|
+
|
|
119
|
+
- 非官方接口,掘金改版后可能直接失效
|
|
120
|
+
- 只轮询每篇文章第一页(最新 20 条评论);两次轮询间超过 20 条新评论会漏
|
|
121
|
+
- 评论列表只附带每条评论**最早的 2 条**回复;之后的新楼内回复不会入站(`reply/list` 接口未能验证,不使用)
|
|
122
|
+
- 已见评论 id 只在内存里,重启后重新建基线
|
|
123
|
+
- 发送请求体来自社区实现([androidZzT/juejin-mcp](https://github.com/androidZzT/juejin-mcp) 在无头浏览器内调用),**纯 HTTP 调用可能需要额外的反爬签名**,未经实测
|
|
124
|
+
- 不提供扫码登录、不做 Cookie 续期
|
|
125
|
+
|
|
126
|
+
## 人工验证清单
|
|
127
|
+
|
|
128
|
+
首次在真实账号上使用发送前,请用一篇自己的测试文章逐项确认:
|
|
129
|
+
|
|
130
|
+
1. 只配置轮询(不配 `cookie`),确认首轮只建基线、新评论能入站、自己的评论被过滤
|
|
131
|
+
2. 配置 `cookie: ${JUEJIN_COOKIE}`,在 Console 点「重新检查登录」,确认显示正确昵称 / uid
|
|
132
|
+
3. 让 AI 生成一条草稿,确认掘金上**没有**出现任何回复
|
|
133
|
+
4. 在自己的测试评论楼上 `juejin-draft send <id>` 一次,到掘金网页确认回复出现在正确的评论楼下(不是顶层评论)
|
|
134
|
+
5. 若发送返回「结果不明」或熔断:不要重试,到网页确认后在 issue 里附上脱敏后的日志
|
|
135
|
+
|
|
136
|
+
## 故障排查
|
|
137
|
+
|
|
138
|
+
| 现象 | 处理 |
|
|
139
|
+
|---|---|
|
|
140
|
+
| 日志 `juejin_circuit_open` | 触发风控熔断。去网页端确认账号状态、必要时完成验证,调大 `pollIntervalSec` 后重启 |
|
|
141
|
+
| 日志 `juejin_login_invalid` / 页面「登录已失效」 | Cookie 失效,更新 `JUEJIN_COOKIE` 后重启 |
|
|
142
|
+
| 日志 `juejin_no_articles` | 没配 `articles`,不会轮询任何内容 |
|
|
143
|
+
| AI 回复没出现在掘金 | 预期行为:只生成草稿(`juejin_draft_queued`),需要审批 |
|
|
144
|
+
| 发送提示结果不明 | 草稿已标记「可能已经发出」,先到掘金确认,不要直接重发 |
|
|
145
|
+
| 日志 `juejin_notify_failed` | `notify` 指向的会话发不出去 |
|
|
146
|
+
| 日志 `juejin_draft_persist_failed` | 数据目录不可写;草稿仍在内存里 |
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
/**
|
|
3
|
+
* Convention entry: discover `adapters/juejin/index.ts` → defineAdapter.
|
|
4
|
+
* Experimental:轮询白名单文章评论;出站只进草稿队列,master 在 Console「掘金评论审批」页或用 `juejin-draft send <id>` 审批后才发出。
|
|
5
|
+
*/
|
|
6
|
+
import { defineAdapter } from 'zhin.js/adapter';
|
|
7
|
+
import { JuejinAdapter } from "../../lib/adapter.js";
|
|
8
|
+
export { JuejinEndpoint } from "../../lib/endpoint.js";
|
|
9
|
+
export default defineAdapter({
|
|
10
|
+
capabilities: ['inbound', 'outbound'],
|
|
11
|
+
// 评论只支持纯文本:Markdown 去格式、交互段降级纯文本,其余段 unsupported。
|
|
12
|
+
segments: {
|
|
13
|
+
supported: ['text', 'mention'],
|
|
14
|
+
html: 'text',
|
|
15
|
+
markdown: 'text',
|
|
16
|
+
interactive: 'text',
|
|
17
|
+
},
|
|
18
|
+
createAdapter: context => new JuejinAdapter(context),
|
|
19
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convention entry: discover `adapters/juejin/index.ts` → defineAdapter.
|
|
3
|
+
* Experimental:轮询白名单文章评论;出站只进草稿队列,master 在 Console「掘金评论审批」页或用 `juejin-draft send <id>` 审批后才发出。
|
|
4
|
+
*/
|
|
5
|
+
import { defineAdapter } from 'zhin.js/adapter';
|
|
6
|
+
import { JuejinAdapter } from '../../src/adapter.js';
|
|
7
|
+
import type { JuejinEndpointConfig } from '../../src/protocol.js';
|
|
8
|
+
|
|
9
|
+
export { JuejinEndpoint } from '../../src/endpoint.js';
|
|
10
|
+
export type { JuejinEndpointOptions } from '../../src/endpoint.js';
|
|
11
|
+
|
|
12
|
+
export default defineAdapter<JuejinEndpointConfig>({
|
|
13
|
+
capabilities: ['inbound', 'outbound'],
|
|
14
|
+
// 评论只支持纯文本:Markdown 去格式、交互段降级纯文本,其余段 unsupported。
|
|
15
|
+
segments: {
|
|
16
|
+
supported: ['text', 'mention'],
|
|
17
|
+
html: 'text',
|
|
18
|
+
markdown: 'text',
|
|
19
|
+
interactive: 'text',
|
|
20
|
+
},
|
|
21
|
+
createAdapter: context => new JuejinAdapter(context),
|
|
22
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { defineCommand } from 'zhin.js/command';
|
|
3
|
+
import { juejinEndpoints, discardDraftText } from '@zhin.js/adapter-juejin';
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
description: '丢弃一条掘金评论草稿(不发送)', descriptionI18n: { en: 'Discard a Juejin comment draft without sending' },
|
|
6
|
+
permit: ['role(master)'],
|
|
7
|
+
params: { id: { type: 'string', description: '列表中方括号内的草稿 ID', descriptionI18n: { en: 'Draft ID shown in square brackets in the list' } } },
|
|
8
|
+
execute(context) {
|
|
9
|
+
const { params, locale, formatCommand } = context;
|
|
10
|
+
const directory = juejinEndpoints(context);
|
|
11
|
+
return discardDraftText(directory, String(params.id ?? '').trim(), { locale, formatCommand });
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineCommand } from 'zhin.js/command';
|
|
2
|
+
import { juejinEndpoints, discardDraftText } from '@zhin.js/adapter-juejin';
|
|
3
|
+
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
description: '丢弃一条掘金评论草稿(不发送)', descriptionI18n: { en: 'Discard a Juejin comment draft without sending' },
|
|
6
|
+
permit: ['role(master)'],
|
|
7
|
+
params: { id: { type: 'string', description: '列表中方括号内的草稿 ID', descriptionI18n: { en: 'Draft ID shown in square brackets in the list' } } },
|
|
8
|
+
execute(context) {
|
|
9
|
+
const { params, locale, formatCommand } = context;
|
|
10
|
+
const directory = juejinEndpoints(context);
|
|
11
|
+
return discardDraftText(directory, String(params.id ?? '').trim(), { locale, formatCommand });
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { defineCommand } from 'zhin.js/command';
|
|
3
|
+
import { juejinEndpoints, editDraftText, draftEditUsage } from '@zhin.js/adapter-juejin';
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
description: '改写一条掘金评论草稿,改完仍需审批发送', descriptionI18n: { en: 'Edit a Juejin comment draft; approval is still required to send' },
|
|
6
|
+
permit: ['role(master)'],
|
|
7
|
+
params: { id: { type: 'string', description: '草稿 ID', descriptionI18n: { en: 'Draft ID' } } },
|
|
8
|
+
execute(context) {
|
|
9
|
+
const { params, args, locale, formatCommand } = context;
|
|
10
|
+
const directory = juejinEndpoints(context);
|
|
11
|
+
const message = args.join(' ').trim();
|
|
12
|
+
if (!message)
|
|
13
|
+
return draftEditUsage({ locale, formatCommand });
|
|
14
|
+
return editDraftText(directory, String(params.id ?? '').trim(), message, { locale, formatCommand });
|
|
15
|
+
},
|
|
16
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineCommand } from 'zhin.js/command';
|
|
2
|
+
import { juejinEndpoints, editDraftText, draftEditUsage } from '@zhin.js/adapter-juejin';
|
|
3
|
+
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
description: '改写一条掘金评论草稿,改完仍需审批发送', descriptionI18n: { en: 'Edit a Juejin comment draft; approval is still required to send' },
|
|
6
|
+
permit: ['role(master)'],
|
|
7
|
+
params: { id: { type: 'string', description: '草稿 ID', descriptionI18n: { en: 'Draft ID' } } },
|
|
8
|
+
execute(context) {
|
|
9
|
+
const { params, args, locale, formatCommand } = context;
|
|
10
|
+
const directory = juejinEndpoints(context);
|
|
11
|
+
const message = args.join(' ').trim();
|
|
12
|
+
if (!message) return draftEditUsage({ locale, formatCommand });
|
|
13
|
+
return editDraftText(directory, String(params.id ?? '').trim(), message, { locale, formatCommand });
|
|
14
|
+
},
|
|
15
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { defineCommand } from 'zhin.js/command';
|
|
3
|
+
import { juejinEndpoints, listDraftsText } from '@zhin.js/adapter-juejin';
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
description: '查看待审的掘金评论回复草稿', descriptionI18n: { en: 'List Juejin comment drafts awaiting review' },
|
|
6
|
+
permit: ['role(master)'],
|
|
7
|
+
execute(context) {
|
|
8
|
+
const { locale, formatCommand } = context;
|
|
9
|
+
const directory = juejinEndpoints(context);
|
|
10
|
+
return listDraftsText(directory, { locale, formatCommand });
|
|
11
|
+
},
|
|
12
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineCommand } from 'zhin.js/command';
|
|
2
|
+
import { juejinEndpoints, listDraftsText } from '@zhin.js/adapter-juejin';
|
|
3
|
+
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
description: '查看待审的掘金评论回复草稿', descriptionI18n: { en: 'List Juejin comment drafts awaiting review' },
|
|
6
|
+
permit: ['role(master)'],
|
|
7
|
+
execute(context) {
|
|
8
|
+
const { locale, formatCommand } = context;
|
|
9
|
+
const directory = juejinEndpoints(context);
|
|
10
|
+
return listDraftsText(directory, { locale, formatCommand });
|
|
11
|
+
},
|
|
12
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { defineCommand } from 'zhin.js/command';
|
|
3
|
+
import { juejinEndpoints, approveDraftText } from '@zhin.js/adapter-juejin';
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
description: '审批并发出一条掘金评论草稿', descriptionI18n: { en: 'Approve and send a Juejin comment draft' },
|
|
6
|
+
permit: ['role(master)'],
|
|
7
|
+
params: { id: { type: 'string', description: '列表中方括号内的草稿 ID', descriptionI18n: { en: 'Draft ID shown in square brackets in the list' } } },
|
|
8
|
+
async execute(context) {
|
|
9
|
+
const { params, locale, formatCommand } = context;
|
|
10
|
+
const directory = juejinEndpoints(context);
|
|
11
|
+
return await approveDraftText(directory, String(params.id ?? '').trim(), { locale, formatCommand });
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineCommand } from 'zhin.js/command';
|
|
2
|
+
import { juejinEndpoints, approveDraftText } from '@zhin.js/adapter-juejin';
|
|
3
|
+
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
description: '审批并发出一条掘金评论草稿', descriptionI18n: { en: 'Approve and send a Juejin comment draft' },
|
|
6
|
+
permit: ['role(master)'],
|
|
7
|
+
params: { id: { type: 'string', description: '列表中方括号内的草稿 ID', descriptionI18n: { en: 'Draft ID shown in square brackets in the list' } } },
|
|
8
|
+
async execute(context) {
|
|
9
|
+
const { params, locale, formatCommand } = context;
|
|
10
|
+
const directory = juejinEndpoints(context);
|
|
11
|
+
return await approveDraftText(directory, String(params.id ?? '').trim(), { locale, formatCommand });
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { defineCommand } from 'zhin.js/command';
|
|
3
|
+
import { juejinEndpoints, showDraftText } from '@zhin.js/adapter-juejin';
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
description: '查看一条掘金评论草稿全文', descriptionI18n: { en: 'Read a complete Juejin comment draft' },
|
|
6
|
+
permit: ['role(master)'],
|
|
7
|
+
params: { id: { type: 'string', description: '列表中方括号内的草稿 ID', descriptionI18n: { en: 'Draft ID shown in square brackets in the list' } } },
|
|
8
|
+
execute(context) {
|
|
9
|
+
const { params, locale, formatCommand } = context;
|
|
10
|
+
const directory = juejinEndpoints(context);
|
|
11
|
+
return showDraftText(directory, String(params.id ?? '').trim(), { locale, formatCommand });
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineCommand } from 'zhin.js/command';
|
|
2
|
+
import { juejinEndpoints, showDraftText } from '@zhin.js/adapter-juejin';
|
|
3
|
+
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
description: '查看一条掘金评论草稿全文', descriptionI18n: { en: 'Read a complete Juejin comment draft' },
|
|
6
|
+
permit: ['role(master)'],
|
|
7
|
+
params: { id: { type: 'string', description: '列表中方括号内的草稿 ID', descriptionI18n: { en: 'Draft ID shown in square brackets in the list' } } },
|
|
8
|
+
execute(context) {
|
|
9
|
+
const { params, locale, formatCommand } = context;
|
|
10
|
+
const directory = juejinEndpoints(context);
|
|
11
|
+
return showDraftText(directory, String(params.id ?? '').trim(), { locale, formatCommand });
|
|
12
|
+
},
|
|
13
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { defineCommand } from 'zhin.js/command';
|
|
3
|
+
import { juejinEndpoints, loginStatusText } from '@zhin.js/adapter-juejin';
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
description: '查看掘金登录状态', descriptionI18n: { en: 'Show the Juejin login status' },
|
|
6
|
+
permit: ['role(master)'],
|
|
7
|
+
execute(context) {
|
|
8
|
+
const { locale } = context;
|
|
9
|
+
const directory = juejinEndpoints(context);
|
|
10
|
+
return loginStatusText(directory, locale);
|
|
11
|
+
},
|
|
12
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineCommand } from 'zhin.js/command';
|
|
2
|
+
import { juejinEndpoints, loginStatusText } from '@zhin.js/adapter-juejin';
|
|
3
|
+
|
|
4
|
+
export default defineCommand({
|
|
5
|
+
description: '查看掘金登录状态', descriptionI18n: { en: 'Show the Juejin login status' },
|
|
6
|
+
permit: ['role(master)'],
|
|
7
|
+
execute(context) {
|
|
8
|
+
const { locale } = context;
|
|
9
|
+
const directory = juejinEndpoints(context);
|
|
10
|
+
return loginStatusText(directory, locale);
|
|
11
|
+
},
|
|
12
|
+
});
|
package/lib/adapter.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Adapter, type AdapterContext, type EndpointCreationContext } from 'zhin.js/adapter';
|
|
2
|
+
import { type JuejinClientEventMap } from './client.js';
|
|
3
|
+
import { JuejinEndpoint } from './endpoint.js';
|
|
4
|
+
import { type JuejinEndpointConfig } from './protocol.js';
|
|
5
|
+
export declare class JuejinAdapter extends Adapter<JuejinEndpoint, JuejinEndpointConfig, JuejinClientEventMap> {
|
|
6
|
+
#private;
|
|
7
|
+
constructor(context: AdapterContext<JuejinEndpointConfig>);
|
|
8
|
+
get configurationIdentityFields(): readonly string[];
|
|
9
|
+
get configurationIdentityProbeFields(): readonly string[];
|
|
10
|
+
get configurationUpdateFields(): readonly string[];
|
|
11
|
+
protected validateConfiguration(config: JuejinEndpointConfig): void;
|
|
12
|
+
protected prepareConfiguration(config: JuejinEndpointConfig, identity: Readonly<{
|
|
13
|
+
instanceId: string;
|
|
14
|
+
}>): JuejinEndpointConfig;
|
|
15
|
+
protected identifyConfiguration(input: JuejinEndpointConfig, signal: AbortSignal): Promise<string | undefined>;
|
|
16
|
+
protected createEndpoint(input: JuejinEndpointConfig, execution: EndpointCreationContext): JuejinEndpoint;
|
|
17
|
+
}
|
|
18
|
+
declare module '@zhin.js/feature-kit' {
|
|
19
|
+
interface AdapterRegistry {
|
|
20
|
+
readonly juejin: JuejinAdapter;
|
|
21
|
+
}
|
|
22
|
+
}
|
package/lib/adapter.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Adapter } from 'zhin.js/adapter';
|
|
2
|
+
import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
|
|
3
|
+
import { JuejinClient } from './client.js';
|
|
4
|
+
import { JuejinEndpoint } from './endpoint.js';
|
|
5
|
+
import { resolveJuejinConfig } from './protocol.js';
|
|
6
|
+
import { juejinNotifierToken } from './juejin-runtime-state.js';
|
|
7
|
+
import { formatNotice } from './review.js';
|
|
8
|
+
export class JuejinAdapter extends Adapter {
|
|
9
|
+
#context;
|
|
10
|
+
constructor(context) { super(); this.#context = context; }
|
|
11
|
+
get configurationIdentityFields() { return ['id']; }
|
|
12
|
+
get configurationIdentityProbeFields() { return ['cookie', 'apiBaseUrl']; }
|
|
13
|
+
get configurationUpdateFields() {
|
|
14
|
+
return ['cookie', 'articles', 'pollIntervalSec', 'sendMode', 'minSendIntervalSec', 'notify', 'apiBaseUrl'];
|
|
15
|
+
}
|
|
16
|
+
validateConfiguration(config) { resolveJuejinConfig(config); }
|
|
17
|
+
prepareConfiguration(config, identity) {
|
|
18
|
+
return { ...config, stateDirectoryId: identity.instanceId };
|
|
19
|
+
}
|
|
20
|
+
async identifyConfiguration(input, signal) {
|
|
21
|
+
const config = resolveJuejinConfig(input);
|
|
22
|
+
if (!config.cookie)
|
|
23
|
+
return undefined;
|
|
24
|
+
const self = await new JuejinClient(config, globalThis.fetch, signal).getSelf();
|
|
25
|
+
signal.throwIfAborted();
|
|
26
|
+
return self ? `juejin:${self.userId}` : undefined;
|
|
27
|
+
}
|
|
28
|
+
createEndpoint(input, execution) {
|
|
29
|
+
const config = resolveJuejinConfig(input);
|
|
30
|
+
const notifier = this.#context.use(juejinNotifierToken);
|
|
31
|
+
const logger = getAdapterLogger('juejin', config.id);
|
|
32
|
+
return new JuejinEndpoint({ id: execution.canonicalId, config, signal: execution.signal,
|
|
33
|
+
stateDir: config.dataDir,
|
|
34
|
+
notify: async (notice) => {
|
|
35
|
+
if (!config.notify)
|
|
36
|
+
return;
|
|
37
|
+
if (!await notifier(config.notify, formatNotice(notice, { formatCommand: path => path }))) {
|
|
38
|
+
logger.info(formatCompact({ op: 'juejin_notify_skipped', kind: notice.kind, hint: '没有可用的出站通道' }));
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
package/lib/client.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
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 { type JuejinApiEnvelope, type JuejinCommentEntry, type ResolvedJuejinConfig } from './protocol.js';
|
|
8
|
+
export type JuejinFetch = (url: string, init?: {
|
|
9
|
+
readonly method?: string;
|
|
10
|
+
readonly headers?: Record<string, string>;
|
|
11
|
+
readonly body?: string;
|
|
12
|
+
readonly signal?: AbortSignal;
|
|
13
|
+
}) => Promise<{
|
|
14
|
+
readonly ok: boolean;
|
|
15
|
+
readonly status: number;
|
|
16
|
+
text(): Promise<string>;
|
|
17
|
+
}>;
|
|
18
|
+
/**
|
|
19
|
+
* 平台返回非 0 `err_no`、非 2xx 或非 JSON。`risk` 为 true 时调用方必须熔断;
|
|
20
|
+
* `ambiguous` 为 true 表示写请求结果未知(可能已经发出),不能当成「没发」重试。
|
|
21
|
+
*/
|
|
22
|
+
export declare class JuejinApiError extends Error {
|
|
23
|
+
readonly code: number;
|
|
24
|
+
readonly risk: boolean;
|
|
25
|
+
readonly ambiguous: boolean;
|
|
26
|
+
constructor(code: number, message: string, options?: {
|
|
27
|
+
readonly httpStatus?: number;
|
|
28
|
+
readonly ambiguous?: boolean;
|
|
29
|
+
});
|
|
30
|
+
readonly httpStatus?: number;
|
|
31
|
+
/** 平台判定未登录 / Cookie 失效。 */
|
|
32
|
+
get loginRequired(): boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface JuejinSelf {
|
|
35
|
+
readonly userId: string;
|
|
36
|
+
readonly userName?: string;
|
|
37
|
+
readonly avatar?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface JuejinCommentPage {
|
|
40
|
+
readonly comments: readonly JuejinCommentEntry[];
|
|
41
|
+
readonly count?: number;
|
|
42
|
+
readonly hasMore?: boolean;
|
|
43
|
+
}
|
|
44
|
+
export interface JuejinReplyInput {
|
|
45
|
+
readonly articleId: string;
|
|
46
|
+
/** 评论楼根(一级评论 id)。 */
|
|
47
|
+
readonly root: string;
|
|
48
|
+
/** 直接回复的对象;等于 root 时回复一级评论,否则回复楼中楼里的那条回复。 */
|
|
49
|
+
readonly parent: string;
|
|
50
|
+
readonly message: string;
|
|
51
|
+
}
|
|
52
|
+
export declare class JuejinClient {
|
|
53
|
+
#private;
|
|
54
|
+
readonly config: ResolvedJuejinConfig;
|
|
55
|
+
readonly fetch: JuejinFetch;
|
|
56
|
+
constructor(config: ResolvedJuejinConfig, fetch: JuejinFetch, signal?: AbortSignal);
|
|
57
|
+
get hasCookie(): boolean;
|
|
58
|
+
/** 切换 Cookie(传 undefined 清空)。 */
|
|
59
|
+
setCookie(cookie: string | undefined): void;
|
|
60
|
+
/**
|
|
61
|
+
* 发请求并解出 `{ err_no, err_msg, data }` 信封。`write` 为 true 时,拿不到可解析的结果
|
|
62
|
+
* (网络中断、超时、空响应、非 JSON)都按「结果未知」抛出 `ambiguous` 错误。
|
|
63
|
+
*/
|
|
64
|
+
request<T>(path: string, init?: {
|
|
65
|
+
readonly method?: 'GET' | 'POST';
|
|
66
|
+
readonly body?: unknown;
|
|
67
|
+
readonly write?: boolean;
|
|
68
|
+
}): Promise<JuejinApiEnvelope<T>>;
|
|
69
|
+
/** 登录态自检:当前账号(未登录 / Cookie 失效返回 undefined)。掘金未登录时返回 `data: null` 或 `err_no: 403`。 */
|
|
70
|
+
getSelf(): Promise<JuejinSelf | undefined>;
|
|
71
|
+
/** 读取文章评论第一页(`sort=0` 最新在前),一级评论带内联的前几条回复。公开接口,不需要 Cookie。 */
|
|
72
|
+
listLatestComments(articleId: string, limit?: number): Promise<JuejinCommentPage>;
|
|
73
|
+
/**
|
|
74
|
+
* 回复评论(`POST interact_api/v1/reply/publish`)。只回复已有评论楼:`reply_to_comment_id` = 楼根,
|
|
75
|
+
* 回复楼中楼时再带 `reply_to_reply_id`。字段取自社区实现(androidZzT/juejin-mcp `browser/comment.go`);
|
|
76
|
+
* 纯 HTTP 调用可能被掘金的反爬签名拒绝,见 docs/adapters/juejin.md「手动验证清单」。
|
|
77
|
+
*
|
|
78
|
+
* 只由 `JuejinEndpoint.approveDraft()` 在人工审批后调用;`send()` 永远不直接调用。
|
|
79
|
+
* 返回新回复 id(平台没给时为 undefined,但 `err_no: 0` 仍视为已发出)。
|
|
80
|
+
*/
|
|
81
|
+
replyComment(input: JuejinReplyInput): Promise<string | undefined>;
|
|
82
|
+
}
|
|
83
|
+
export type JuejinClientEventMap = Record<string, unknown>;
|