@wecode-ai/weibo-openclaw-plugin 1.0.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.
@@ -0,0 +1,345 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { Type, type Static } from "@sinclair/typebox";
3
+
4
+ // ============ Schema ============
5
+
6
+ export const WeiboHotSearchSchema = Type.Object({
7
+ category: Type.String({
8
+ description:
9
+ "榜单类型(中文名称):主榜、文娱榜、社会榜、生活榜、acg榜、科技榜、体育榜",
10
+ enum: ["主榜", "文娱榜", "社会榜", "生活榜", "acg榜", "科技榜", "体育榜"],
11
+ }),
12
+ count: Type.Optional(
13
+ Type.Number({
14
+ description: "返回条数,范围 1-50,默认为 50",
15
+ minimum: 1,
16
+ maximum: 50,
17
+ })
18
+ ),
19
+ });
20
+
21
+ export type WeiboHotSearchParams = Static<typeof WeiboHotSearchSchema>;
22
+
23
+ // ============ Helpers ============
24
+
25
+ function json(data: unknown) {
26
+ return {
27
+ content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
28
+ details: data,
29
+ };
30
+ }
31
+
32
+ // ============ API Types ============
33
+
34
+ /**
35
+ * 热搜榜 API 响应结构
36
+ * API: http://open-im.api.weibo.com/open/weibo/hot_search
37
+ */
38
+ export type WeiboHotSearchApiResponse = {
39
+ code: number;
40
+ message: string;
41
+ data: {
42
+ callTime?: string;
43
+ source?: string;
44
+ data: WeiboHotSearchItem[];
45
+ };
46
+ };
47
+
48
+ export type WeiboHotSearchItem = {
49
+ cat: string;
50
+ id: number;
51
+ word: string;
52
+ num: number;
53
+ flag: number;
54
+ app_query_link: string;
55
+ h5_query_link: string;
56
+ flag_link: string;
57
+ };
58
+
59
+ // ============ Category Mapping ============
60
+
61
+ /**
62
+ * 榜单类型映射:中文名称 -> 内部标识 (sid)
63
+ */
64
+ const CATEGORY_MAP: Record<string, string> = {
65
+ 主榜: "v_openclaw",
66
+ 文娱榜: "v_openclaw_ent",
67
+ 社会榜: "v_openclaw_social",
68
+ 生活榜: "v_openclaw_live",
69
+ acg榜: "v_openclaw_acg",
70
+ 科技榜: "v_openclaw_tech",
71
+ 体育榜: "v_openclaw_sport",
72
+ };
73
+
74
+ // ============ Token Management ============
75
+
76
+ // Token 过期时间:2小时(7200秒),提前60秒刷新
77
+ const TOKEN_EXPIRE_SECONDS = 7200;
78
+ const TOKEN_REFRESH_BUFFER_SECONDS = 60;
79
+
80
+ // 默认 token 端点
81
+ const DEFAULT_TOKEN_ENDPOINT = "http://open-im.api.weibo.com/open/auth/ws_token";
82
+
83
+ type HotSearchTokenCache = {
84
+ token: string;
85
+ acquiredAt: number;
86
+ expiresIn: number;
87
+ };
88
+
89
+ // 热搜专用的 token 缓存
90
+ let hotSearchTokenCache: HotSearchTokenCache | null = null;
91
+
92
+ type TokenResponse = {
93
+ data: {
94
+ token: string;
95
+ expire_in: number;
96
+ };
97
+ };
98
+
99
+ /**
100
+ * 获取热搜用的 token
101
+ * 通过 http://open-im.api.weibo.com/open/auth/ws_token 获取
102
+ * token 过期时间为 2 小时
103
+ */
104
+ async function fetchHotSearchToken(
105
+ appId: string,
106
+ appSecret: string,
107
+ tokenEndpoint?: string
108
+ ): Promise<HotSearchTokenCache> {
109
+ const endpoint = tokenEndpoint || DEFAULT_TOKEN_ENDPOINT;
110
+
111
+ const response = await fetch(endpoint, {
112
+ method: "POST",
113
+ headers: {
114
+ "Content-Type": "application/json",
115
+ },
116
+ body: JSON.stringify({
117
+ app_id: appId,
118
+ app_secret: appSecret,
119
+ }),
120
+ });
121
+
122
+ if (!response.ok) {
123
+ const errorText = await response.text().catch(() => "");
124
+ throw new Error(
125
+ `获取热搜 token 失败: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ""}`
126
+ );
127
+ }
128
+
129
+ const result = (await response.json()) as TokenResponse;
130
+
131
+ if (!result.data?.token) {
132
+ throw new Error("获取热搜 token 失败: 响应中缺少 token");
133
+ }
134
+
135
+ const tokenCache: HotSearchTokenCache = {
136
+ token: result.data.token,
137
+ acquiredAt: Date.now(),
138
+ expiresIn: result.data.expire_in || TOKEN_EXPIRE_SECONDS,
139
+ };
140
+
141
+ hotSearchTokenCache = tokenCache;
142
+ return tokenCache;
143
+ }
144
+
145
+ /**
146
+ * 获取有效的热搜 token
147
+ * 如果缓存的 token 未过期则返回缓存,否则重新获取
148
+ */
149
+ async function getValidHotSearchToken(
150
+ appId: string,
151
+ appSecret: string,
152
+ tokenEndpoint?: string
153
+ ): Promise<string> {
154
+ // 检查缓存的 token 是否有效
155
+ if (hotSearchTokenCache) {
156
+ const expiresAt =
157
+ hotSearchTokenCache.acquiredAt +
158
+ hotSearchTokenCache.expiresIn * 1000 -
159
+ TOKEN_REFRESH_BUFFER_SECONDS * 1000;
160
+ if (Date.now() < expiresAt) {
161
+ return hotSearchTokenCache.token;
162
+ }
163
+ }
164
+
165
+ // 获取新 token
166
+ const tokenResult = await fetchHotSearchToken(appId, appSecret, tokenEndpoint);
167
+ return tokenResult.token;
168
+ }
169
+
170
+ // ============ Core Functions ============
171
+
172
+ // 默认热搜端点
173
+ const DEFAULT_HOT_SEARCH_ENDPOINT = "http://open-im.api.weibo.com/open/weibo/hot_search";
174
+
175
+ /**
176
+ * 获取微博热搜榜
177
+ * 使用 token 认证方式访问
178
+ */
179
+ async function fetchHotSearch(
180
+ token: string,
181
+ category: string,
182
+ count?: number,
183
+ endpoint?: string
184
+ ): Promise<WeiboHotSearchApiResponse> {
185
+ const apiEndpoint = endpoint || DEFAULT_HOT_SEARCH_ENDPOINT;
186
+
187
+ const url = new URL(apiEndpoint);
188
+ url.searchParams.set("token", token);
189
+ url.searchParams.set("category", category);
190
+ if (count !== undefined) {
191
+ url.searchParams.set("count", String(count));
192
+ }
193
+
194
+ const response = await fetch(url.toString(), {
195
+ method: "GET",
196
+ headers: {
197
+ "Content-Type": "application/json;charset=UTF-8",
198
+ },
199
+ });
200
+
201
+ if (!response.ok) {
202
+ const errorText = await response.text().catch(() => "");
203
+ throw new Error(
204
+ `获取热搜榜失败: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ""}`
205
+ );
206
+ }
207
+
208
+ const result = (await response.json()) as WeiboHotSearchApiResponse;
209
+ return result;
210
+ }
211
+
212
+ /**
213
+ * 格式化热搜结果
214
+ */
215
+ function formatHotSearchResult(result: WeiboHotSearchApiResponse, category: string) {
216
+ if (result.code !== 0) {
217
+ return {
218
+ success: false,
219
+ error: result.message || "获取热搜榜失败",
220
+ };
221
+ }
222
+
223
+ const data = result.data;
224
+
225
+ if (!data.data || data.data.length === 0) {
226
+ return {
227
+ success: true,
228
+ category,
229
+ total: 0,
230
+ callTime: data.callTime,
231
+ source: data.source,
232
+ items: [],
233
+ message: "没有找到热搜内容",
234
+ };
235
+ }
236
+
237
+ return {
238
+ success: true,
239
+ category,
240
+ total: data.data.length,
241
+ callTime: data.callTime,
242
+ source: data.source,
243
+ items: data.data.map((item) => ({
244
+ rank: item.id,
245
+ word: item.word,
246
+ hotValue: item.num,
247
+ category: item.cat,
248
+ flag: item.flag,
249
+ appLink: item.app_query_link,
250
+ h5Link: item.h5_query_link,
251
+ flagIcon: item.flag_link,
252
+ })),
253
+ };
254
+ }
255
+
256
+ // ============ Configuration Types ============
257
+
258
+ export type WeiboHotSearchConfig = {
259
+ /** 热搜 API 端点,默认为 open-im.api.weibo.com */
260
+ weiboHotSearchEndpoint?: string;
261
+ /** App ID,用于获取 token */
262
+ appId?: string;
263
+ /** App Secret,用于获取 token */
264
+ appSecret?: string;
265
+ /** Token 端点,默认为 http://open-im.api.weibo.com/open/auth/ws_token */
266
+ tokenEndpoint?: string;
267
+ /** 是否启用热搜工具,默认为 true */
268
+ enabled?: boolean;
269
+ };
270
+
271
+ function getHotSearchConfig(api: OpenClawPluginApi): WeiboHotSearchConfig {
272
+ const weiboCfg = api.config?.channels?.weibo as Record<string, unknown> | undefined;
273
+ return {
274
+ weiboHotSearchEndpoint: weiboCfg?.weiboHotSearchEndpoint as string | undefined,
275
+ appId: weiboCfg?.appId as string | undefined,
276
+ appSecret: weiboCfg?.appSecret as string | undefined,
277
+ tokenEndpoint: weiboCfg?.tokenEndpoint as string | undefined,
278
+ enabled: weiboCfg?.weiboHotSearchEnabled !== false,
279
+ };
280
+ }
281
+
282
+ // ============ Tool Registration ============
283
+
284
+ export function registerWeiboHotSearchTools(api: OpenClawPluginApi) {
285
+ const cfg = getHotSearchConfig(api);
286
+
287
+ // 检查是否禁用了工具
288
+ if (!cfg.enabled) {
289
+ api.logger.debug?.("weibo_hot_search: Tool disabled, skipping registration");
290
+ return;
291
+ }
292
+
293
+ // 检查是否配置了认证信息
294
+ if (!cfg.appId || !cfg.appSecret) {
295
+ api.logger.warn?.("weibo_hot_search: appId or appSecret not configured, tool disabled");
296
+ return;
297
+ }
298
+
299
+ const appId = cfg.appId;
300
+ const appSecret = cfg.appSecret;
301
+
302
+ api.registerTool(
303
+ () => ({
304
+ name: "weibo_hot_search",
305
+ label: "Weibo Hot Search",
306
+ description:
307
+ "获取微博热搜榜。支持多种榜单类型:主榜、文娱榜、社会榜、生活榜、acg榜、科技榜、体育榜。返回热搜词、热度值、排名等信息。使用此工具获取数据后,必须使用查询的榜单类型以及返回的 `callTime` 和 `source` 字段内容注明数据来源, 格式: {category}, 2026-03-12 12:00,来自于微博热搜。",
308
+ parameters: WeiboHotSearchSchema,
309
+ async execute(_toolCallId, params) {
310
+ const p = params as WeiboHotSearchParams;
311
+ try {
312
+ // 验证榜单类型
313
+ if (!CATEGORY_MAP[p.category]) {
314
+ return json({
315
+ success: false,
316
+ error: `无效的榜单类型: ${p.category}。支持的类型: ${Object.keys(CATEGORY_MAP).join("、")}`,
317
+ });
318
+ }
319
+
320
+ // 获取有效的 token
321
+ const token = await getValidHotSearchToken(
322
+ appId,
323
+ appSecret,
324
+ cfg.tokenEndpoint
325
+ );
326
+
327
+ const result = await fetchHotSearch(
328
+ token,
329
+ p.category,
330
+ p.count,
331
+ cfg.weiboHotSearchEndpoint
332
+ );
333
+
334
+ return json(formatHotSearchResult(result, p.category));
335
+ } catch (err) {
336
+ return json({
337
+ error: err instanceof Error ? err.message : String(err),
338
+ });
339
+ }
340
+ },
341
+ }),
342
+ { name: "weibo_hot_search" }
343
+ );
344
+ api.logger.info?.("weibo_hot_search: Registered weibo_hot_search tool");
345
+ }
@@ -0,0 +1,333 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { WeiboSearchSchema, type WeiboSearchParams } from "./search-schema.js";
3
+
4
+ // ============ Helpers ============
5
+
6
+ function json(data: unknown) {
7
+ return {
8
+ content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
9
+ details: data,
10
+ };
11
+ }
12
+
13
+ // ============ API Types ============
14
+
15
+ /**
16
+ * 微博搜索 API 响应结构
17
+ * API: http://open-im.api.weibo.com/open/wis/search_query
18
+ */
19
+ export type WeiboSearchApiResponse = {
20
+ code: number;
21
+ message: string;
22
+ data: {
23
+ analyzing: boolean;
24
+ completed: boolean;
25
+ msg: string;
26
+ msg_format: string;
27
+ msg_json: string;
28
+ noContent: boolean;
29
+ profile_image_url: string;
30
+ reference_num: number;
31
+ refused: boolean;
32
+ scheme: string;
33
+ status: number;
34
+ status_stage: number;
35
+ version: string;
36
+ callTime?: string;
37
+ source?: string;
38
+ };
39
+ };
40
+ // 保留旧类型以兼容可能的其他 API 格式
41
+ export type WeiboSearchStatusItem = {
42
+ id: string;
43
+ mid: string;
44
+ text: string;
45
+ source: string;
46
+ created_at: string;
47
+ user: {
48
+ id: string;
49
+ screen_name: string;
50
+ profile_image_url: string;
51
+ followers_count: number;
52
+ friends_count: number;
53
+ statuses_count: number;
54
+ verified: boolean;
55
+ verified_type: number;
56
+ description: string;
57
+ };
58
+ reposts_count: number;
59
+ comments_count: number;
60
+ attitudes_count: number;
61
+ pic_urls?: Array<{ thumbnail_pic: string }>;
62
+ retweeted_status?: WeiboSearchStatusItem;
63
+ };
64
+
65
+ export type WeiboSearchResponse = {
66
+ statuses: WeiboSearchStatusItem[];
67
+ total_number: number;
68
+ previous_cursor: number;
69
+ next_cursor: number;
70
+ };
71
+
72
+ // ============ Token Management ============
73
+
74
+ // Token 过期时间:2小时(7200秒),提前60秒刷新
75
+ const TOKEN_EXPIRE_SECONDS = 7200;
76
+ const TOKEN_REFRESH_BUFFER_SECONDS = 60;
77
+
78
+ // 默认 token 端点
79
+ const DEFAULT_TOKEN_ENDPOINT = "http://open-im.api.weibo.com/open/auth/ws_token";
80
+
81
+ type SearchTokenCache = {
82
+ token: string;
83
+ acquiredAt: number;
84
+ expiresIn: number;
85
+ };
86
+
87
+ // 搜索专用的 token 缓存
88
+ let searchTokenCache: SearchTokenCache | null = null;
89
+
90
+ type SearchTokenResponse = {
91
+ data: {
92
+ token: string;
93
+ expire_in: number;
94
+ };
95
+ };
96
+
97
+ /**
98
+ * 获取搜索用的 token
99
+ * 通过 http://open-im.api.weibo.com/open/auth/ws_token 获取
100
+ * token 过期时间为 2 小时
101
+ */
102
+ async function fetchSearchToken(
103
+ appId: string,
104
+ appSecret: string,
105
+ tokenEndpoint?: string
106
+ ): Promise<SearchTokenCache> {
107
+ const endpoint = tokenEndpoint || DEFAULT_TOKEN_ENDPOINT;
108
+
109
+ const response = await fetch(endpoint, {
110
+ method: "POST",
111
+ headers: {
112
+ "Content-Type": "application/json",
113
+ },
114
+ body: JSON.stringify({
115
+ app_id: appId,
116
+ app_secret: appSecret,
117
+ }),
118
+ });
119
+
120
+ if (!response.ok) {
121
+ const errorText = await response.text().catch(() => "");
122
+ throw new Error(
123
+ `获取搜索 token 失败: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ""}`
124
+ );
125
+ }
126
+
127
+ const result = (await response.json()) as SearchTokenResponse;
128
+
129
+ if (!result.data?.token) {
130
+ throw new Error("获取搜索 token 失败: 响应中缺少 token");
131
+ }
132
+
133
+ const tokenCache: SearchTokenCache = {
134
+ token: result.data.token,
135
+ acquiredAt: Date.now(),
136
+ expiresIn: result.data.expire_in || TOKEN_EXPIRE_SECONDS,
137
+ };
138
+
139
+ searchTokenCache = tokenCache;
140
+ return tokenCache;
141
+ }
142
+
143
+ /**
144
+ * 获取有效的搜索 token
145
+ * 如果缓存的 token 未过期则返回缓存,否则重新获取
146
+ */
147
+ async function getValidSearchToken(
148
+ appId: string,
149
+ appSecret: string,
150
+ tokenEndpoint?: string
151
+ ): Promise<string> {
152
+ // 检查缓存的 token 是否有效
153
+ if (searchTokenCache) {
154
+ const expiresAt =
155
+ searchTokenCache.acquiredAt +
156
+ searchTokenCache.expiresIn * 1000 -
157
+ TOKEN_REFRESH_BUFFER_SECONDS * 1000;
158
+ if (Date.now() < expiresAt) {
159
+ return searchTokenCache.token;
160
+ }
161
+ }
162
+
163
+ // 获取新 token
164
+ const tokenResult = await fetchSearchToken(appId, appSecret, tokenEndpoint);
165
+ return tokenResult.token;
166
+ }
167
+
168
+ // ============ Core Functions ============
169
+
170
+ // 默认搜索端点
171
+ const DEFAULT_SEARCH_ENDPOINT = "http://open-im.api.weibo.com/open/wis/search_query";
172
+
173
+ /**
174
+ * 搜索微博内容
175
+ * 使用 token 认证方式访问
176
+ */
177
+ async function searchWeibo(
178
+ query: string,
179
+ token: string,
180
+ weiboSearchEndpoint?: string
181
+ ): Promise<WeiboSearchApiResponse> {
182
+ const endpoint = weiboSearchEndpoint || DEFAULT_SEARCH_ENDPOINT;
183
+
184
+ const url = new URL(endpoint);
185
+ url.searchParams.set("query", query);
186
+ url.searchParams.set("token", token);
187
+
188
+ const response = await fetch(url.toString(), {
189
+ method: "GET",
190
+ headers: {
191
+ "Content-Type": "application/json",
192
+ },
193
+ });
194
+
195
+ if (!response.ok) {
196
+ const errorText = await response.text().catch(() => "");
197
+ throw new Error(
198
+ `微博搜索失败: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ""}`
199
+ );
200
+ }
201
+
202
+ const result = (await response.json()) as WeiboSearchApiResponse;
203
+ return result;
204
+ }
205
+
206
+ /**
207
+ * 格式化搜索结果
208
+ */
209
+ function formatSearchResult(result: WeiboSearchApiResponse) {
210
+ if (result.code !== 0) {
211
+ return {
212
+ success: false,
213
+ error: result.message || "搜索失败",
214
+ };
215
+ }
216
+
217
+ const data = result.data;
218
+
219
+ // 如果没有内容
220
+ if (data.noContent) {
221
+ return {
222
+ success: true,
223
+ completed: data.completed,
224
+ noContent: true,
225
+ callTime: data.callTime,
226
+ source: data.source,
227
+ message: "没有找到相关内容",
228
+ };
229
+ }
230
+
231
+ // 如果被拒绝
232
+ if (data.refused) {
233
+ return {
234
+ success: false,
235
+ error: "搜索请求被拒绝",
236
+ };
237
+ }
238
+
239
+ return {
240
+ success: true,
241
+ completed: data.completed,
242
+ analyzing: data.analyzing,
243
+ content: data.msg,
244
+ contentFormat: data.msg_format,
245
+ referenceCount: data.reference_num,
246
+ scheme: data.scheme,
247
+ version: data.version,
248
+ callTime: data.callTime,
249
+ source: data.source,
250
+ };
251
+ }
252
+
253
+ // ============ Configuration Types ============
254
+
255
+ export type WeiboSearchConfig = {
256
+ /** 搜索 API 端点,默认为 open-im.api.weibo.com */
257
+ weiboSearchEndpoint?: string;
258
+ /** App ID,用于获取 token */
259
+ appId?: string;
260
+ /** App Secret,用于获取 token */
261
+ appSecret?: string;
262
+ /** Token 端点,默认为 http://open-im.api.weibo.com/open/auth/ws_token */
263
+ tokenEndpoint?: string;
264
+ /** 是否启用搜索工具,默认为 true */
265
+ enabled?: boolean;
266
+ };
267
+
268
+ function getSearchConfig(api: OpenClawPluginApi): WeiboSearchConfig {
269
+ const weiboCfg = api.config?.channels?.weibo as Record<string, unknown> | undefined;
270
+ return {
271
+ weiboSearchEndpoint: weiboCfg?.weiboSearchEndpoint as string | undefined,
272
+ appId: weiboCfg?.appId as string | undefined,
273
+ appSecret: weiboCfg?.appSecret as string | undefined,
274
+ tokenEndpoint: weiboCfg?.tokenEndpoint as string | undefined,
275
+ enabled: weiboCfg?.weiboSearchEnabled !== false,
276
+ };
277
+ }
278
+
279
+ // ============ Tool Registration ============
280
+
281
+ export function registerWeiboSearchTools(api: OpenClawPluginApi) {
282
+ const searchCfg = getSearchConfig(api);
283
+
284
+ // 检查是否禁用了搜索工具
285
+ if (!searchCfg.enabled) {
286
+ api.logger.debug?.("weibo_search: Search tool disabled, skipping registration");
287
+ return;
288
+ }
289
+
290
+ // 检查是否配置了认证信息
291
+ if (!searchCfg.appId || !searchCfg.appSecret) {
292
+ api.logger.warn?.("weibo_search: appId or appSecret not configured, search tool disabled");
293
+ return;
294
+ }
295
+
296
+ const appId = searchCfg.appId;
297
+ const appSecret = searchCfg.appSecret;
298
+
299
+ api.registerTool(
300
+ () => ({
301
+ name: "weibo_search",
302
+ label: "Weibo Search",
303
+ description:
304
+ "微博智搜工具,通过关键词获取微博智搜内容。使用此工具获取数据后,必须使用查询关键词以及返回的 `callTime` 和 `source` 字段内容注明数据来源, 格式: 关键词: {query}, 2026-03-12 12:00,来自于微博智搜。查询结果可直接使用",
305
+ parameters: WeiboSearchSchema,
306
+ async execute(_toolCallId, params) {
307
+ const p = params as WeiboSearchParams;
308
+ try {
309
+ // 获取有效的 token
310
+ const token = await getValidSearchToken(
311
+ appId,
312
+ appSecret,
313
+ searchCfg.tokenEndpoint
314
+ );
315
+
316
+ const result = await searchWeibo(
317
+ p.query,
318
+ token,
319
+ searchCfg.weiboSearchEndpoint
320
+ );
321
+
322
+ return json(formatSearchResult(result));
323
+ } catch (err) {
324
+ return json({
325
+ error: err instanceof Error ? err.message : String(err),
326
+ });
327
+ }
328
+ },
329
+ }),
330
+ { name: "weibo_search" }
331
+ );
332
+ api.logger.info?.("weibo_search: Registered weibo_search tool");
333
+ }