fanfou 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/dist/client.js ADDED
@@ -0,0 +1,490 @@
1
+ import { authorizationHeader, fanfouSignatureURL, percentEncode } from "./oauth1.js";
2
+ export class FanfouHttpError extends Error {
3
+ status;
4
+ body;
5
+ constructor(status, body) {
6
+ super(`HTTP ${status}: ${body.slice(0, 500)}`);
7
+ this.name = "FanfouHttpError";
8
+ this.status = status;
9
+ this.body = body;
10
+ }
11
+ }
12
+ function compact(pairs) {
13
+ if (!pairs)
14
+ return [];
15
+ const out = [];
16
+ for (const [k, v] of pairs) {
17
+ if (v !== undefined && v !== null && v !== "")
18
+ out.push([k, v]);
19
+ }
20
+ return out;
21
+ }
22
+ export class FanfouClient {
23
+ apiBaseURL;
24
+ oauthBaseURL;
25
+ consumerKey;
26
+ consumerSecret;
27
+ token;
28
+ tokenSecret;
29
+ dryRun;
30
+ userAgent;
31
+ source;
32
+ timeoutMs;
33
+ constructor(config) {
34
+ this.apiBaseURL = config.apiBaseURL ?? "https://api.fanfou.com/";
35
+ this.oauthBaseURL = config.oauthBaseURL ?? "https://fanfou.com/";
36
+ this.consumerKey = config.consumerKey;
37
+ this.consumerSecret = config.consumerSecret;
38
+ this.token = config.token;
39
+ this.tokenSecret = config.tokenSecret;
40
+ this.dryRun = config.dryRun ?? false;
41
+ this.userAgent = config.userAgent ?? "fanfou-cli/0.1";
42
+ this.source = config.source ?? "fanfou-cli";
43
+ this.timeoutMs = config.timeoutMs ?? 30000;
44
+ }
45
+ get isAuthenticated() {
46
+ return Boolean(this.token && this.tokenSecret);
47
+ }
48
+ buildURL(base, path, query) {
49
+ const url = new URL(path.replace(/^\//, ""), base);
50
+ for (const [k, v] of query)
51
+ url.searchParams.append(k, v);
52
+ return url;
53
+ }
54
+ /** Builds the signed request description without performing it (used by --dry-run). */
55
+ plan(opts) {
56
+ const base = opts.base === "oauth" ? this.oauthBaseURL : this.apiBaseURL;
57
+ const query = compact(opts.query);
58
+ const form = compact(opts.form);
59
+ const url = this.buildURL(base, opts.path, query);
60
+ const bodyForSignature = opts.file ? [] : form;
61
+ const authorization = authorizationHeader({
62
+ method: opts.method,
63
+ url,
64
+ consumerKey: this.consumerKey,
65
+ consumerSecret: this.consumerSecret,
66
+ token: this.token,
67
+ tokenSecret: this.tokenSecret ?? "",
68
+ extraParameters: opts.oauthExtra ?? [],
69
+ bodyParameters: bodyForSignature,
70
+ signatureURL: fanfouSignatureURL(url),
71
+ });
72
+ const plan = {
73
+ method: opts.method,
74
+ url: url.toString(),
75
+ query: Object.fromEntries(query),
76
+ authorization,
77
+ };
78
+ if (opts.file) {
79
+ plan.multipartFields = Object.fromEntries(compact(opts.multipart));
80
+ plan.file = {
81
+ fieldName: opts.file.fieldName,
82
+ fileName: opts.file.fileName,
83
+ mimeType: opts.file.mimeType,
84
+ bytes: opts.file.data.length,
85
+ };
86
+ }
87
+ else if (form.length > 0) {
88
+ plan.form = Object.fromEntries(form);
89
+ }
90
+ return plan;
91
+ }
92
+ async request(opts) {
93
+ const plan = this.plan(opts);
94
+ if (this.dryRun) {
95
+ return { status: 0, text: "", plan };
96
+ }
97
+ const headers = {
98
+ "User-Agent": this.userAgent,
99
+ Accept: "application/json",
100
+ Authorization: plan.authorization,
101
+ };
102
+ let body;
103
+ if (opts.file) {
104
+ const fd = new FormData();
105
+ for (const [k, v] of compact(opts.multipart))
106
+ fd.append(k, v);
107
+ fd.append(opts.file.fieldName, new Blob([new Uint8Array(opts.file.data)], { type: opts.file.mimeType }), opts.file.fileName);
108
+ body = fd;
109
+ }
110
+ else {
111
+ const form = compact(opts.form);
112
+ if (form.length > 0) {
113
+ headers["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8";
114
+ body = form.map(([k, v]) => `${percentEncode(k)}=${percentEncode(v)}`).join("&");
115
+ }
116
+ }
117
+ const controller = new AbortController();
118
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
119
+ let response;
120
+ try {
121
+ response = await fetch(plan.url, {
122
+ method: opts.method,
123
+ headers,
124
+ body,
125
+ signal: controller.signal,
126
+ });
127
+ }
128
+ finally {
129
+ clearTimeout(timer);
130
+ }
131
+ const text = await response.text();
132
+ if (response.status < 200 || response.status >= 300) {
133
+ throw new FanfouHttpError(response.status, text);
134
+ }
135
+ return { status: response.status, text, plan };
136
+ }
137
+ async requestJSON(opts) {
138
+ const { text, plan } = await this.request(opts);
139
+ if (this.dryRun)
140
+ return { dryRun: true, request: plan };
141
+ if (!text)
142
+ throw new Error("饭否没有返回内容 (empty response)");
143
+ try {
144
+ return JSON.parse(text);
145
+ }
146
+ catch {
147
+ throw new Error(`JSON 解析失败 (could not parse response):\n${text.slice(0, 300)}`);
148
+ }
149
+ }
150
+ async requestText(opts) {
151
+ const { text } = await this.request(opts);
152
+ return text;
153
+ }
154
+ // ---- OAuth / auth -----------------------------------------------------
155
+ static parseTokenResponse(raw) {
156
+ const values = {};
157
+ for (const component of raw.trim().split("&")) {
158
+ const idx = component.indexOf("=");
159
+ if (idx < 0)
160
+ continue;
161
+ const key = decodeURIComponent(component.slice(0, idx));
162
+ const value = decodeURIComponent(component.slice(idx + 1));
163
+ values[key] = value;
164
+ }
165
+ const token = values["oauth_token"] ?? "";
166
+ const secret = values["oauth_token_secret"] ?? "";
167
+ if (!token || !secret)
168
+ throw new Error(`无效的 token 响应 (invalid token response): ${raw.slice(0, 200)}`);
169
+ const extra = { ...values };
170
+ delete extra["oauth_token"];
171
+ delete extra["oauth_token_secret"];
172
+ return { token, secret, extra };
173
+ }
174
+ /** XAuth: exchange username + password directly for an access token. */
175
+ async xauthLogin(username, password) {
176
+ const text = await this.requestText({
177
+ base: "oauth",
178
+ path: "oauth/access_token",
179
+ method: "GET",
180
+ oauthExtra: [
181
+ ["x_auth_username", username],
182
+ ["x_auth_password", password],
183
+ ["x_auth_mode", "client_auth"],
184
+ ],
185
+ });
186
+ const parsed = FanfouClient.parseTokenResponse(text);
187
+ return { token: parsed.token, secret: parsed.secret };
188
+ }
189
+ /** OAuth step 1: obtain a temporary request token. */
190
+ async requestToken(callback = "oob") {
191
+ const text = await this.requestText({
192
+ base: "oauth",
193
+ path: "oauth/request_token",
194
+ method: "GET",
195
+ oauthExtra: callback ? [["oauth_callback", callback]] : [],
196
+ });
197
+ const parsed = FanfouClient.parseTokenResponse(text);
198
+ return { token: parsed.token, secret: parsed.secret };
199
+ }
200
+ authorizeURL(requestToken, callback) {
201
+ const url = new URL("oauth/authorize", this.oauthBaseURL);
202
+ url.searchParams.set("oauth_token", requestToken);
203
+ if (callback)
204
+ url.searchParams.set("oauth_callback", callback);
205
+ return url.toString();
206
+ }
207
+ /** OAuth step 2: exchange an authorized request token for an access token. */
208
+ async accessToken(requestToken, requestTokenSecret, verifier) {
209
+ const tmp = new FanfouClient({
210
+ apiBaseURL: this.apiBaseURL,
211
+ oauthBaseURL: this.oauthBaseURL,
212
+ consumerKey: this.consumerKey,
213
+ consumerSecret: this.consumerSecret,
214
+ token: requestToken,
215
+ tokenSecret: requestTokenSecret,
216
+ dryRun: this.dryRun,
217
+ });
218
+ const text = await tmp.requestText({
219
+ base: "oauth",
220
+ path: "oauth/access_token",
221
+ method: "GET",
222
+ oauthExtra: verifier ? [["oauth_verifier", verifier]] : [],
223
+ });
224
+ const parsed = FanfouClient.parseTokenResponse(text);
225
+ return { token: parsed.token, secret: parsed.secret };
226
+ }
227
+ // ---- Account ----------------------------------------------------------
228
+ verifyCredentials() {
229
+ return this.requestJSON({ path: "account/verify_credentials.json", method: "GET" });
230
+ }
231
+ notification() {
232
+ return this.requestJSON({ path: "account/notification.json", method: "GET" });
233
+ }
234
+ updateProfile(fields) {
235
+ return this.requestJSON({
236
+ path: "account/update_profile.json",
237
+ method: "POST",
238
+ form: [
239
+ ["name", fields.name],
240
+ ["location", fields.location],
241
+ ["url", fields.url],
242
+ ["description", fields.description],
243
+ ],
244
+ });
245
+ }
246
+ updateProfileImage(file) {
247
+ return this.requestJSON({
248
+ path: "account/update_profile_image.json",
249
+ method: "POST",
250
+ file: { ...file, fieldName: "image" },
251
+ });
252
+ }
253
+ // ---- Timelines --------------------------------------------------------
254
+ timeline(path, opts) {
255
+ return this.requestJSON({
256
+ path,
257
+ method: "GET",
258
+ query: [
259
+ ["id", opts.id],
260
+ ["since_id", opts.sinceId],
261
+ ["max_id", opts.maxId],
262
+ ["count", opts.count != null ? String(opts.count) : undefined],
263
+ ["page", opts.page != null ? String(opts.page) : undefined],
264
+ ],
265
+ });
266
+ }
267
+ homeTimeline(opts = {}) {
268
+ return this.timeline("statuses/home_timeline.json", opts);
269
+ }
270
+ publicTimeline(opts = {}) {
271
+ return this.timeline("statuses/public_timeline.json", opts);
272
+ }
273
+ userTimeline(opts = {}) {
274
+ return this.timeline("statuses/user_timeline.json", opts);
275
+ }
276
+ mentions(opts = {}) {
277
+ return this.timeline("statuses/mentions.json", opts);
278
+ }
279
+ contextTimeline(id) {
280
+ return this.requestJSON({ path: "statuses/context_timeline.json", method: "GET", query: [["id", id]] });
281
+ }
282
+ photoUserTimeline(opts = {}) {
283
+ return this.timeline("photos/user_timeline.json", opts);
284
+ }
285
+ // ---- Search -----------------------------------------------------------
286
+ searchPublicTimeline(q, opts = {}) {
287
+ return this.requestJSON({
288
+ path: "search/public_timeline.json",
289
+ method: "GET",
290
+ query: [
291
+ ["q", q],
292
+ ["since_id", opts.sinceId],
293
+ ["max_id", opts.maxId],
294
+ ["count", opts.count != null ? String(opts.count) : undefined],
295
+ ],
296
+ });
297
+ }
298
+ searchUsers(q, opts = {}) {
299
+ return this.requestJSON({
300
+ path: "search/users.json",
301
+ method: "GET",
302
+ query: [
303
+ ["q", q],
304
+ ["count", opts.count != null ? String(opts.count) : undefined],
305
+ ["page", opts.page != null ? String(opts.page) : undefined],
306
+ ],
307
+ });
308
+ }
309
+ // ---- Statuses ---------------------------------------------------------
310
+ showStatus(id) {
311
+ return this.requestJSON({ path: "statuses/show.json", method: "GET", query: [["id", id]] });
312
+ }
313
+ updateStatus(opts) {
314
+ return this.requestJSON({
315
+ path: "statuses/update.json",
316
+ method: "POST",
317
+ form: [
318
+ ["status", opts.status],
319
+ ["in_reply_to_status_id", opts.inReplyToStatusId],
320
+ ["in_reply_to_user_id", opts.inReplyToUserId],
321
+ ["repost_status_id", opts.repostStatusId],
322
+ ["location", opts.location],
323
+ ["source", this.source],
324
+ ],
325
+ });
326
+ }
327
+ uploadPhoto(file, status, location) {
328
+ return this.requestJSON({
329
+ path: "photos/upload.json",
330
+ method: "POST",
331
+ multipart: [
332
+ ["status", status],
333
+ ["location", location ?? ""],
334
+ ["source", this.source],
335
+ ],
336
+ file: { ...file, fieldName: "photo" },
337
+ });
338
+ }
339
+ destroyStatus(id) {
340
+ return this.requestJSON({ path: "statuses/destroy.json", method: "POST", form: [["id", id]] });
341
+ }
342
+ // ---- Users & relationships -------------------------------------------
343
+ showUser(id) {
344
+ return this.requestJSON({ path: "users/show.json", method: "GET", query: [["id", id]] });
345
+ }
346
+ friends(opts = {}) {
347
+ return this.requestJSON({
348
+ path: "users/friends.json",
349
+ method: "GET",
350
+ query: [
351
+ ["id", opts.id],
352
+ ["count", opts.count != null ? String(opts.count) : undefined],
353
+ ["page", opts.page != null ? String(opts.page) : undefined],
354
+ ],
355
+ });
356
+ }
357
+ followers(opts = {}) {
358
+ return this.requestJSON({
359
+ path: "users/followers.json",
360
+ method: "GET",
361
+ query: [
362
+ ["id", opts.id],
363
+ ["count", opts.count != null ? String(opts.count) : undefined],
364
+ ["page", opts.page != null ? String(opts.page) : undefined],
365
+ ],
366
+ });
367
+ }
368
+ followUser(id) {
369
+ return this.requestJSON({ path: "friendships/create.json", method: "POST", form: [["id", id]] });
370
+ }
371
+ unfollowUser(id) {
372
+ return this.requestJSON({ path: "friendships/destroy.json", method: "POST", form: [["id", id]] });
373
+ }
374
+ async friendshipExists(userA, userB) {
375
+ const text = await this.requestText({
376
+ path: "friendships/exists.json",
377
+ method: "GET",
378
+ query: [
379
+ ["user_a", userA],
380
+ ["user_b", userB],
381
+ ],
382
+ });
383
+ return text.trim().toLowerCase() === "true";
384
+ }
385
+ friendshipRequests(opts = {}) {
386
+ return this.requestJSON({
387
+ path: "friendships/requests.json",
388
+ method: "GET",
389
+ query: [
390
+ ["count", opts.count != null ? String(opts.count) : undefined],
391
+ ["page", opts.page != null ? String(opts.page) : undefined],
392
+ ],
393
+ });
394
+ }
395
+ acceptFriendship(id) {
396
+ return this.requestJSON({ path: "friendships/accept.json", method: "POST", form: [["id", id]] });
397
+ }
398
+ denyFriendship(id) {
399
+ return this.requestJSON({ path: "friendships/deny.json", method: "POST", form: [["id", id]] });
400
+ }
401
+ // ---- Blocks -----------------------------------------------------------
402
+ blockedUsers(opts = {}) {
403
+ return this.requestJSON({
404
+ path: "blocks/blocking.json",
405
+ method: "GET",
406
+ query: [
407
+ ["count", opts.count != null ? String(opts.count) : undefined],
408
+ ["page", opts.page != null ? String(opts.page) : undefined],
409
+ ],
410
+ });
411
+ }
412
+ blockUser(id) {
413
+ return this.requestJSON({ path: "blocks/create.json", method: "POST", form: [["id", id]] });
414
+ }
415
+ unblockUser(id) {
416
+ return this.requestJSON({ path: "blocks/destroy.json", method: "POST", form: [["id", id]] });
417
+ }
418
+ async isBlocked(id) {
419
+ try {
420
+ await this.requestJSON({ path: "blocks/exists.json", method: "GET", query: [["id", id]] });
421
+ return true;
422
+ }
423
+ catch (err) {
424
+ if (err instanceof FanfouHttpError && (err.status === 403 || err.status === 404))
425
+ return false;
426
+ throw err;
427
+ }
428
+ }
429
+ // ---- Favorites --------------------------------------------------------
430
+ favorites(opts = {}) {
431
+ return this.requestJSON({
432
+ path: "favorites.json",
433
+ method: "GET",
434
+ query: [
435
+ ["id", opts.id],
436
+ ["count", opts.count != null ? String(opts.count) : undefined],
437
+ ["page", opts.page != null ? String(opts.page) : undefined],
438
+ ],
439
+ });
440
+ }
441
+ createFavorite(statusId) {
442
+ return this.requestJSON({ path: `favorites/create/${statusId}.json`, method: "POST" });
443
+ }
444
+ destroyFavorite(statusId) {
445
+ return this.requestJSON({ path: `favorites/destroy/${statusId}.json`, method: "POST" });
446
+ }
447
+ // ---- Direct messages --------------------------------------------------
448
+ conversationList(opts = {}) {
449
+ return this.requestJSON({
450
+ path: "direct_messages/conversation_list.json",
451
+ method: "GET",
452
+ query: [
453
+ ["count", opts.count != null ? String(opts.count) : undefined],
454
+ ["page", opts.page != null ? String(opts.page) : undefined],
455
+ ],
456
+ });
457
+ }
458
+ conversation(id, opts = {}) {
459
+ return this.requestJSON({
460
+ path: "direct_messages/conversation.json",
461
+ method: "GET",
462
+ query: [
463
+ ["id", id],
464
+ ["since_id", opts.sinceId],
465
+ ["max_id", opts.maxId],
466
+ ["count", opts.count != null ? String(opts.count) : undefined],
467
+ ],
468
+ });
469
+ }
470
+ inbox(opts = {}) {
471
+ return this.timeline("direct_messages/inbox.json", opts);
472
+ }
473
+ sent(opts = {}) {
474
+ return this.timeline("direct_messages/sent.json", opts);
475
+ }
476
+ sendDirectMessage(opts) {
477
+ return this.requestJSON({
478
+ path: "direct_messages/new.json",
479
+ method: "POST",
480
+ form: [
481
+ ["user", opts.user],
482
+ ["text", opts.text],
483
+ ["in_reply_to_id", opts.inReplyToId],
484
+ ],
485
+ });
486
+ }
487
+ deleteDirectMessage(id) {
488
+ return this.requestJSON({ path: "direct_messages/destroy.json", method: "POST", form: [["id", id]] });
489
+ }
490
+ }