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/LICENSE +21 -0
- package/README.md +122 -0
- package/dist/args.js +91 -0
- package/dist/client.js +490 -0
- package/dist/commands.js +777 -0
- package/dist/config.js +81 -0
- package/dist/index.js +100 -0
- package/dist/oauth1.js +73 -0
- package/dist/output.js +80 -0
- package/dist/registry.js +122 -0
- package/package.json +45 -0
- package/skills/fanfou/SKILL.md +122 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
|
|
4
|
+
// Default consumer credentials shared with the existing Fanfou clients.
|
|
5
|
+
export const DEFAULT_CONSUMER_KEY = "175d9183cc2a7298abed2ca2280daa2a";
|
|
6
|
+
export const DEFAULT_CONSUMER_SECRET = "7c541eab37d4a8be432119c3fcf5c3a0";
|
|
7
|
+
export function configDir() {
|
|
8
|
+
if (process.env.FANFOU_CONFIG_DIR)
|
|
9
|
+
return process.env.FANFOU_CONFIG_DIR;
|
|
10
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
11
|
+
return join(base, "fanfou");
|
|
12
|
+
}
|
|
13
|
+
function configPath() {
|
|
14
|
+
return join(configDir(), "config.json");
|
|
15
|
+
}
|
|
16
|
+
function emptyConfig() {
|
|
17
|
+
return { currentProfile: "default", profiles: {} };
|
|
18
|
+
}
|
|
19
|
+
export function loadConfig() {
|
|
20
|
+
const path = configPath();
|
|
21
|
+
if (!existsSync(path))
|
|
22
|
+
return emptyConfig();
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
25
|
+
if (!parsed.profiles)
|
|
26
|
+
parsed.profiles = {};
|
|
27
|
+
if (!parsed.currentProfile)
|
|
28
|
+
parsed.currentProfile = "default";
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return emptyConfig();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function saveConfig(config) {
|
|
36
|
+
const dir = configDir();
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
const path = configPath();
|
|
39
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
|
40
|
+
try {
|
|
41
|
+
chmodSync(path, 0o600);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
/* best effort */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function resolveProfileName(explicit) {
|
|
48
|
+
return explicit || process.env.FANFOU_PROFILE || loadConfig().currentProfile || "default";
|
|
49
|
+
}
|
|
50
|
+
/** Merge stored profile with environment overrides; env always wins. */
|
|
51
|
+
export function resolveProfile(explicit) {
|
|
52
|
+
const config = loadConfig();
|
|
53
|
+
const name = resolveProfileName(explicit);
|
|
54
|
+
const stored = config.profiles[name] ?? {};
|
|
55
|
+
const profile = {
|
|
56
|
+
consumerKey: process.env.FANFOU_CONSUMER_KEY || stored.consumerKey || DEFAULT_CONSUMER_KEY,
|
|
57
|
+
consumerSecret: process.env.FANFOU_CONSUMER_SECRET || stored.consumerSecret || DEFAULT_CONSUMER_SECRET,
|
|
58
|
+
token: process.env.FANFOU_OAUTH_TOKEN || stored.token,
|
|
59
|
+
tokenSecret: process.env.FANFOU_OAUTH_TOKEN_SECRET || stored.tokenSecret,
|
|
60
|
+
user: stored.user,
|
|
61
|
+
};
|
|
62
|
+
return { name, profile };
|
|
63
|
+
}
|
|
64
|
+
export function saveProfile(name, profile, setCurrent = true) {
|
|
65
|
+
const config = loadConfig();
|
|
66
|
+
config.profiles[name] = { ...config.profiles[name], ...profile };
|
|
67
|
+
if (setCurrent)
|
|
68
|
+
config.currentProfile = name;
|
|
69
|
+
saveConfig(config);
|
|
70
|
+
}
|
|
71
|
+
export function clearProfile(name) {
|
|
72
|
+
const config = loadConfig();
|
|
73
|
+
delete config.profiles[name];
|
|
74
|
+
if (config.currentProfile === name)
|
|
75
|
+
config.currentProfile = "default";
|
|
76
|
+
saveConfig(config);
|
|
77
|
+
}
|
|
78
|
+
export function listProfiles() {
|
|
79
|
+
const config = loadConfig();
|
|
80
|
+
return { current: config.currentProfile, names: Object.keys(config.profiles) };
|
|
81
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs, flagBool, flagString } from "./args.js";
|
|
3
|
+
import { buildRootCommand } from "./commands.js";
|
|
4
|
+
import { UsageError } from "./commands.js";
|
|
5
|
+
import { FanfouClient, FanfouHttpError } from "./client.js";
|
|
6
|
+
import { resolveProfile } from "./config.js";
|
|
7
|
+
import { collectBooleanFlags, commandSchema, renderHelpText, resolveCommand, } from "./registry.js";
|
|
8
|
+
import { isOutputFormat, printData, printError } from "./output.js";
|
|
9
|
+
const VERSION = "0.1.0";
|
|
10
|
+
function resolveFormat(flags) {
|
|
11
|
+
if (flagBool(flags, "json"))
|
|
12
|
+
return "json";
|
|
13
|
+
const explicit = flagString(flags, "format");
|
|
14
|
+
if (explicit) {
|
|
15
|
+
if (isOutputFormat(explicit))
|
|
16
|
+
return explicit;
|
|
17
|
+
throw new UsageError(`未知的输出格式:${explicit}(可选 json|ndjson|table|raw)`);
|
|
18
|
+
}
|
|
19
|
+
return "json";
|
|
20
|
+
}
|
|
21
|
+
async function main() {
|
|
22
|
+
const argv = process.argv.slice(2);
|
|
23
|
+
const root = buildRootCommand();
|
|
24
|
+
const booleanFlags = collectBooleanFlags(root);
|
|
25
|
+
const { positionals, flags } = parseArgs(argv, booleanFlags);
|
|
26
|
+
if (flagBool(flags, "version")) {
|
|
27
|
+
process.stdout.write(VERSION + "\n");
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
let format;
|
|
31
|
+
try {
|
|
32
|
+
format = resolveFormat(flags);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
printError({ type: "usage", message: err.message }, "json");
|
|
36
|
+
return 2;
|
|
37
|
+
}
|
|
38
|
+
const { command, path, rest } = resolveCommand(root, positionals);
|
|
39
|
+
const wantsHelp = flagBool(flags, "help");
|
|
40
|
+
const hasRun = typeof command.run === "function";
|
|
41
|
+
if (wantsHelp || !hasRun) {
|
|
42
|
+
if (format === "json") {
|
|
43
|
+
printData(commandSchema(command, path), "json");
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
process.stdout.write(renderHelpText(command, path) + "\n");
|
|
47
|
+
}
|
|
48
|
+
// No runnable command and no help requested = usage error exit code.
|
|
49
|
+
return hasRun || wantsHelp ? 0 : positionals.length === 0 ? 0 : 2;
|
|
50
|
+
}
|
|
51
|
+
const dryRun = flagBool(flags, "dry-run");
|
|
52
|
+
const profileName = flagString(flags, "profile") ?? "";
|
|
53
|
+
const { name, profile } = resolveProfile(profileName || undefined);
|
|
54
|
+
const client = new FanfouClient({
|
|
55
|
+
consumerKey: profile.consumerKey,
|
|
56
|
+
consumerSecret: profile.consumerSecret,
|
|
57
|
+
token: profile.token,
|
|
58
|
+
tokenSecret: profile.tokenSecret,
|
|
59
|
+
dryRun,
|
|
60
|
+
});
|
|
61
|
+
if (command.requiresAuth && !client.isAuthenticated && !dryRun) {
|
|
62
|
+
printError({
|
|
63
|
+
type: "auth_required",
|
|
64
|
+
message: `命令 "${path.join(" ")}" 需要登录`,
|
|
65
|
+
hint: "先运行:fanfou auth login -u <用户名> -p <密码>,或用 --dry-run 预览请求",
|
|
66
|
+
}, format);
|
|
67
|
+
return 3;
|
|
68
|
+
}
|
|
69
|
+
const ctx = {
|
|
70
|
+
client,
|
|
71
|
+
args: rest,
|
|
72
|
+
flags,
|
|
73
|
+
format,
|
|
74
|
+
dryRun,
|
|
75
|
+
profileName: name,
|
|
76
|
+
};
|
|
77
|
+
const result = await command.run(ctx);
|
|
78
|
+
printData(result, format);
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
main()
|
|
82
|
+
.then((code) => process.exit(code))
|
|
83
|
+
.catch((err) => {
|
|
84
|
+
const format = "json";
|
|
85
|
+
if (err instanceof UsageError) {
|
|
86
|
+
printError({ type: "usage", message: err.message }, format);
|
|
87
|
+
process.exit(2);
|
|
88
|
+
}
|
|
89
|
+
if (err instanceof FanfouHttpError) {
|
|
90
|
+
printError({
|
|
91
|
+
type: "http_error",
|
|
92
|
+
message: err.message.split("\n")[0] ?? err.message,
|
|
93
|
+
status: err.status,
|
|
94
|
+
body: err.body.slice(0, 1000),
|
|
95
|
+
}, format);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
printError({ type: "error", message: err?.message ?? String(err) }, format);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
});
|
package/dist/oauth1.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
2
|
+
/** RFC 3986 percent-encoding (unreserved set only). */
|
|
3
|
+
export function percentEncode(value) {
|
|
4
|
+
return encodeURIComponent(value).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
|
|
5
|
+
}
|
|
6
|
+
function baseURLString(url) {
|
|
7
|
+
const scheme = url.protocol.replace(/:$/, "");
|
|
8
|
+
const host = url.hostname;
|
|
9
|
+
let portPart = "";
|
|
10
|
+
if (url.port &&
|
|
11
|
+
!((scheme === "http" && url.port === "80") || (scheme === "https" && url.port === "443"))) {
|
|
12
|
+
portPart = `:${url.port}`;
|
|
13
|
+
}
|
|
14
|
+
const path = url.pathname && url.pathname.length > 0 ? url.pathname : "/";
|
|
15
|
+
return `${scheme}://${host}${portPart}${path}`;
|
|
16
|
+
}
|
|
17
|
+
function queryPairs(url) {
|
|
18
|
+
const pairs = [];
|
|
19
|
+
for (const [name, value] of url.searchParams.entries()) {
|
|
20
|
+
pairs.push([name, value]);
|
|
21
|
+
}
|
|
22
|
+
return pairs;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Fanfou signs the base string with an http:// scheme even though requests go over
|
|
26
|
+
* https. Mirror the iOS client's `fanfouSignatureURL` quirk so signatures validate.
|
|
27
|
+
*/
|
|
28
|
+
export function fanfouSignatureURL(url) {
|
|
29
|
+
if (url.protocol === "https:" && url.hostname.endsWith("fanfou.com")) {
|
|
30
|
+
const copy = new URL(url.toString());
|
|
31
|
+
copy.protocol = "http:";
|
|
32
|
+
return copy;
|
|
33
|
+
}
|
|
34
|
+
return url;
|
|
35
|
+
}
|
|
36
|
+
export function sign(baseString, consumerSecret, tokenSecret) {
|
|
37
|
+
const key = `${percentEncode(consumerSecret)}&${percentEncode(tokenSecret)}`;
|
|
38
|
+
return createHmac("sha1", key).update(baseString, "utf8").digest("base64");
|
|
39
|
+
}
|
|
40
|
+
export function authorizationHeader(opts) {
|
|
41
|
+
const nonce = opts.nonce ?? randomBytes(16).toString("hex");
|
|
42
|
+
const timestamp = opts.timestampSeconds ?? String(Math.floor(Date.now() / 1000));
|
|
43
|
+
const oauthParameters = [
|
|
44
|
+
["oauth_consumer_key", opts.consumerKey],
|
|
45
|
+
["oauth_nonce", nonce],
|
|
46
|
+
["oauth_signature_method", "HMAC-SHA1"],
|
|
47
|
+
["oauth_timestamp", timestamp],
|
|
48
|
+
["oauth_version", "1.0"],
|
|
49
|
+
];
|
|
50
|
+
if (opts.token && opts.token.length > 0) {
|
|
51
|
+
oauthParameters.push(["oauth_token", opts.token]);
|
|
52
|
+
}
|
|
53
|
+
if (opts.extraParameters) {
|
|
54
|
+
oauthParameters.push(...opts.extraParameters);
|
|
55
|
+
}
|
|
56
|
+
const allParameters = [
|
|
57
|
+
...oauthParameters,
|
|
58
|
+
...queryPairs(opts.url),
|
|
59
|
+
...(opts.bodyParameters ?? []),
|
|
60
|
+
];
|
|
61
|
+
const encoded = allParameters.map(([k, v]) => [percentEncode(k), percentEncode(v)]);
|
|
62
|
+
encoded.sort((a, b) => (a[0] === b[0] ? (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0) : a[0] < b[0] ? -1 : 1));
|
|
63
|
+
const signingParameters = encoded.map(([k, v]) => `${k}=${v}`).join("&");
|
|
64
|
+
const baseString = [
|
|
65
|
+
opts.method.toUpperCase(),
|
|
66
|
+
percentEncode(baseURLString(opts.signatureURL ?? opts.url)),
|
|
67
|
+
percentEncode(signingParameters),
|
|
68
|
+
].join("&");
|
|
69
|
+
const signature = sign(baseString, opts.consumerSecret, opts.tokenSecret ?? "");
|
|
70
|
+
const headerParameters = [...oauthParameters, ["oauth_signature", signature]];
|
|
71
|
+
return ("OAuth " +
|
|
72
|
+
headerParameters.map(([k, v]) => `${percentEncode(k)}="${percentEncode(v)}"`).join(", "));
|
|
73
|
+
}
|
package/dist/output.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export function isOutputFormat(value) {
|
|
2
|
+
return value === "json" || value === "ndjson" || value === "table" || value === "raw";
|
|
3
|
+
}
|
|
4
|
+
function asArray(data) {
|
|
5
|
+
return Array.isArray(data) ? data : null;
|
|
6
|
+
}
|
|
7
|
+
function truncate(value, max) {
|
|
8
|
+
const oneLine = value.replace(/\s+/g, " ").trim();
|
|
9
|
+
return oneLine.length > max ? oneLine.slice(0, max - 1) + "…" : oneLine;
|
|
10
|
+
}
|
|
11
|
+
/** Best-effort one-line summary for a Fanfou status or user object. */
|
|
12
|
+
function summarizeRow(item) {
|
|
13
|
+
if (item == null || typeof item !== "object")
|
|
14
|
+
return null;
|
|
15
|
+
const o = item;
|
|
16
|
+
// Status-like
|
|
17
|
+
if (typeof o["text"] === "string" && o["user"] && typeof o["user"] === "object") {
|
|
18
|
+
const user = o["user"];
|
|
19
|
+
const name = user["name"] ?? user["id"] ?? "?";
|
|
20
|
+
const id = o["id"] ?? "";
|
|
21
|
+
const created = o["created_at"] ?? "";
|
|
22
|
+
const fav = o["favorited"] ? " ★" : "";
|
|
23
|
+
return `${id}\t@${name}${fav}\t${truncate(o["text"], 70)}\t${created}`;
|
|
24
|
+
}
|
|
25
|
+
// Direct message-like
|
|
26
|
+
if (typeof o["text"] === "string" && (o["sender_id"] || o["sender"])) {
|
|
27
|
+
const sender = o["sender"]?.["name"] ?? o["sender_id"];
|
|
28
|
+
const id = o["id"] ?? "";
|
|
29
|
+
return `${id}\t@${sender}\t${truncate(o["text"], 70)}`;
|
|
30
|
+
}
|
|
31
|
+
// User-like
|
|
32
|
+
if (typeof o["id"] === "string" && (o["screen_name"] || o["name"])) {
|
|
33
|
+
const followers = o["followers_count"] ?? "";
|
|
34
|
+
const statuses = o["statuses_count"] ?? "";
|
|
35
|
+
return `${o["id"]}\t${o["name"] ?? ""}\tfollowers=${followers}\tstatuses=${statuses}`;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
function renderTable(data) {
|
|
40
|
+
const arr = asArray(data);
|
|
41
|
+
if (arr) {
|
|
42
|
+
const lines = arr.map((item) => summarizeRow(item) ?? JSON.stringify(item));
|
|
43
|
+
return lines.join("\n");
|
|
44
|
+
}
|
|
45
|
+
return summarizeRow(data) ?? JSON.stringify(data, null, 2);
|
|
46
|
+
}
|
|
47
|
+
export function formatOutput(data, format) {
|
|
48
|
+
switch (format) {
|
|
49
|
+
case "json":
|
|
50
|
+
return JSON.stringify(data, null, 2);
|
|
51
|
+
case "ndjson": {
|
|
52
|
+
const arr = asArray(data);
|
|
53
|
+
if (arr)
|
|
54
|
+
return arr.map((item) => JSON.stringify(item)).join("\n");
|
|
55
|
+
return JSON.stringify(data);
|
|
56
|
+
}
|
|
57
|
+
case "table":
|
|
58
|
+
return renderTable(data);
|
|
59
|
+
case "raw":
|
|
60
|
+
return typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function printData(data, format) {
|
|
64
|
+
if (data === undefined)
|
|
65
|
+
return;
|
|
66
|
+
const out = formatOutput(data, format);
|
|
67
|
+
if (out.length > 0)
|
|
68
|
+
process.stdout.write(out + "\n");
|
|
69
|
+
}
|
|
70
|
+
export function printError(error, format) {
|
|
71
|
+
if (format === "table" || format === "raw") {
|
|
72
|
+
let line = `错误 (${error.type}): ${error.message}`;
|
|
73
|
+
if (error.hint)
|
|
74
|
+
line += `\n提示: ${error.hint}`;
|
|
75
|
+
process.stderr.write(line + "\n");
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
process.stderr.write(JSON.stringify({ error }, null, 2) + "\n");
|
|
79
|
+
}
|
|
80
|
+
}
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export const GLOBAL_BOOLEAN_FLAGS = ["help", "dry-run", "verbose", "quiet", "version", "json", "no-color"];
|
|
2
|
+
export function collectBooleanFlags(root) {
|
|
3
|
+
const set = new Set(GLOBAL_BOOLEAN_FLAGS);
|
|
4
|
+
const walk = (cmd) => {
|
|
5
|
+
for (const flag of cmd.flags ?? []) {
|
|
6
|
+
if (flag.type === "boolean") {
|
|
7
|
+
set.add(flag.name);
|
|
8
|
+
if (flag.alias)
|
|
9
|
+
set.add(flag.alias);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
for (const sub of cmd.subcommands ?? [])
|
|
13
|
+
walk(sub);
|
|
14
|
+
};
|
|
15
|
+
walk(root);
|
|
16
|
+
return set;
|
|
17
|
+
}
|
|
18
|
+
export function resolveCommand(root, positionals) {
|
|
19
|
+
let command = root;
|
|
20
|
+
const path = [];
|
|
21
|
+
let i = 0;
|
|
22
|
+
while (i < positionals.length) {
|
|
23
|
+
const token = positionals[i];
|
|
24
|
+
const next = command.subcommands?.find((c) => c.name === token);
|
|
25
|
+
if (!next)
|
|
26
|
+
break;
|
|
27
|
+
command = next;
|
|
28
|
+
path.push(token);
|
|
29
|
+
i++;
|
|
30
|
+
}
|
|
31
|
+
return { command, path, rest: positionals.slice(i) };
|
|
32
|
+
}
|
|
33
|
+
export function commandSchema(command, path) {
|
|
34
|
+
return {
|
|
35
|
+
name: command.name,
|
|
36
|
+
path: ["fanfou", ...path].join(" "),
|
|
37
|
+
summary: command.summary,
|
|
38
|
+
description: command.description,
|
|
39
|
+
requiresAuth: command.requiresAuth ?? false,
|
|
40
|
+
mutates: command.mutates ?? false,
|
|
41
|
+
arguments: (command.args ?? []).map((a) => ({
|
|
42
|
+
name: a.name,
|
|
43
|
+
description: a.description,
|
|
44
|
+
required: a.required ?? false,
|
|
45
|
+
variadic: a.variadic ?? false,
|
|
46
|
+
})),
|
|
47
|
+
flags: (command.flags ?? []).map((f) => ({
|
|
48
|
+
name: f.name,
|
|
49
|
+
alias: f.alias,
|
|
50
|
+
type: f.type,
|
|
51
|
+
description: f.description,
|
|
52
|
+
required: f.required ?? false,
|
|
53
|
+
default: f.default,
|
|
54
|
+
})),
|
|
55
|
+
subcommands: (command.subcommands ?? []).map((s) => ({ name: s.name, summary: s.summary })),
|
|
56
|
+
examples: command.examples ?? [],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function usageLine(command, path) {
|
|
60
|
+
const parts = ["fanfou", ...path];
|
|
61
|
+
if (command.subcommands && command.subcommands.length > 0)
|
|
62
|
+
parts.push("<command>");
|
|
63
|
+
for (const arg of command.args ?? []) {
|
|
64
|
+
const token = arg.variadic ? `${arg.name}...` : arg.name;
|
|
65
|
+
parts.push(arg.required ? `<${token}>` : `[${token}]`);
|
|
66
|
+
}
|
|
67
|
+
if ((command.flags ?? []).length > 0)
|
|
68
|
+
parts.push("[flags]");
|
|
69
|
+
return parts.join(" ");
|
|
70
|
+
}
|
|
71
|
+
export function renderHelpText(command, path) {
|
|
72
|
+
const lines = [];
|
|
73
|
+
const title = path.length > 0 ? path.join(" ") : "fanfou";
|
|
74
|
+
lines.push(`${title} — ${command.summary}`);
|
|
75
|
+
lines.push("");
|
|
76
|
+
if (command.description) {
|
|
77
|
+
lines.push(command.description);
|
|
78
|
+
lines.push("");
|
|
79
|
+
}
|
|
80
|
+
lines.push("Usage:");
|
|
81
|
+
lines.push(` ${usageLine(command, path)}`);
|
|
82
|
+
if (command.args && command.args.length > 0) {
|
|
83
|
+
lines.push("");
|
|
84
|
+
lines.push("Arguments:");
|
|
85
|
+
for (const arg of command.args) {
|
|
86
|
+
const flag = arg.required ? " (required)" : "";
|
|
87
|
+
lines.push(` ${arg.name.padEnd(18)} ${arg.description}${flag}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (command.flags && command.flags.length > 0) {
|
|
91
|
+
lines.push("");
|
|
92
|
+
lines.push("Flags:");
|
|
93
|
+
for (const flag of command.flags) {
|
|
94
|
+
const alias = flag.alias ? `, -${flag.alias}` : "";
|
|
95
|
+
const valueHint = flag.type === "boolean" ? "" : ` <${flag.type}>`;
|
|
96
|
+
const head = `--${flag.name}${alias}${valueHint}`;
|
|
97
|
+
const req = flag.required ? " (required)" : "";
|
|
98
|
+
const def = flag.default !== undefined ? ` [default: ${flag.default}]` : "";
|
|
99
|
+
lines.push(` ${head.padEnd(26)} ${flag.description}${req}${def}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (command.subcommands && command.subcommands.length > 0) {
|
|
103
|
+
lines.push("");
|
|
104
|
+
lines.push("Commands:");
|
|
105
|
+
for (const sub of command.subcommands) {
|
|
106
|
+
lines.push(` ${sub.name.padEnd(18)} ${sub.summary}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (command.examples && command.examples.length > 0) {
|
|
110
|
+
lines.push("");
|
|
111
|
+
lines.push("Examples:");
|
|
112
|
+
for (const ex of command.examples)
|
|
113
|
+
lines.push(` ${ex}`);
|
|
114
|
+
}
|
|
115
|
+
lines.push("");
|
|
116
|
+
lines.push("Global flags:");
|
|
117
|
+
lines.push(" --format, -o <json|ndjson|table|raw> Output format (default: json)");
|
|
118
|
+
lines.push(" --profile <name> Account profile to use");
|
|
119
|
+
lines.push(" --dry-run, -n Print the request without sending it");
|
|
120
|
+
lines.push(" --help, -h Show help (add --format json for schema)");
|
|
121
|
+
return lines.join("\n");
|
|
122
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fanfou",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "An LLM-friendly command line for the Fanfou (饭否) API, with a bundled agent skill.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"fanfou": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"skills",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22.6.0"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"dev": "node src/index.ts",
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
21
|
+
"test": "node --test 'test/**/*.test.ts'",
|
|
22
|
+
"install-skill": "node scripts/install-skill.mjs",
|
|
23
|
+
"prepublishOnly": "npm run build"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"fanfou",
|
|
27
|
+
"饭否",
|
|
28
|
+
"cli",
|
|
29
|
+
"llm",
|
|
30
|
+
"agent",
|
|
31
|
+
"oauth1"
|
|
32
|
+
],
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"author": "Leaking",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/Leaking/fanfou-cli.git"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/Leaking/fanfou-cli#readme",
|
|
40
|
+
"bugs": "https://github.com/Leaking/fanfou-cli/issues",
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^22.10.0",
|
|
43
|
+
"typescript": "^5.8.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fanfou
|
|
3
|
+
description: >-
|
|
4
|
+
Read and write Fanfou (饭否) through the `fanfou` command-line tool. Use when the
|
|
5
|
+
user wants to browse their Fanfou home/public/mentions timeline, post / reply /
|
|
6
|
+
repost / delete a status (发饭/回复/转发), favorite (收藏), follow or search users,
|
|
7
|
+
send direct messages (私信), or call any Fanfou API endpoint. Triggers on "饭否",
|
|
8
|
+
"fanfou", "发饭", "刷饭", or requests mentioning the fanfou CLI.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Fanfou (饭否) via the `fanfou` CLI
|
|
12
|
+
|
|
13
|
+
`fanfou` is an LLM-friendly command line over the Fanfou API. It prints **JSON to
|
|
14
|
+
stdout by default**, so parse stdout as JSON. Errors are JSON on **stderr** with a
|
|
15
|
+
non-zero exit code: `{ "error": { "type", "message", "status?", "body?", "hint?" } }`.
|
|
16
|
+
|
|
17
|
+
## Running the CLI
|
|
18
|
+
|
|
19
|
+
Install the CLI once, then call `fanfou` directly:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g fanfou # global; or one-off without installing: npx fanfou <command>
|
|
23
|
+
fanfou <command> ...
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
(Developing from a clone? Node ≥ 22.6 runs the source with no build:
|
|
27
|
+
`node src/index.ts <command> ...`.)
|
|
28
|
+
|
|
29
|
+
Discover anything at runtime — every command supports `--help` (and
|
|
30
|
+
`--help --format json` returns a machine-readable schema of args/flags):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
fanfou --help # top-level command list
|
|
34
|
+
fanfou status post --help # text help for one command
|
|
35
|
+
fanfou timeline home --help --format json # schema for tooling
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Three layers (pick the simplest that fits)
|
|
39
|
+
|
|
40
|
+
1. **Shortcuts** — `+`-prefixed, high-frequency, smart defaults:
|
|
41
|
+
`+timeline`, `+post`, `+reply`, `+repost`, `+mentions`, `+me`, `+search`, `+fav`, `+dm`.
|
|
42
|
+
2. **Resource commands** — full coverage grouped by resource:
|
|
43
|
+
`auth`, `timeline`, `status`, `favorite`, `user`, `friendship`, `dm`, `account`, `search`.
|
|
44
|
+
3. **Raw API** — `fanfou api <GET|POST> <path> [--query k=v&..] [--form k=v&..]`
|
|
45
|
+
for any endpoint not covered above.
|
|
46
|
+
|
|
47
|
+
## Authentication (required for most commands)
|
|
48
|
+
|
|
49
|
+
Two flows are supported; tokens are stored per-profile under `~/.config/fanfou`.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# XAuth (simplest, recommended for automation): username + password
|
|
53
|
+
fanfou auth login -u <用户名/邮箱> -p <密码>
|
|
54
|
+
# or via env, to keep secrets out of argv / shell history:
|
|
55
|
+
FANFOU_USERNAME=... FANFOU_PASSWORD=... fanfou auth login
|
|
56
|
+
|
|
57
|
+
# OAuth web flow (two scriptable steps):
|
|
58
|
+
fanfou auth oauth-url # prints authorize_url + request token
|
|
59
|
+
# -> open authorize_url in a browser, approve
|
|
60
|
+
fanfou auth oauth-exchange --token <request_token> --secret <request_token_secret> [--verifier <code>]
|
|
61
|
+
|
|
62
|
+
fanfou auth status # check login state (no network)
|
|
63
|
+
fanfou auth whoami # verify against the server
|
|
64
|
+
fanfou auth logout
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Multiple accounts: `fanfou auth login --profile work ...`, then add
|
|
68
|
+
`--profile work` to any command, or `fanfou auth use work` to set the default.
|
|
69
|
+
|
|
70
|
+
## Common recipes
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
fanfou +timeline --count 10 # home timeline, latest 10
|
|
74
|
+
fanfou timeline mentions --count 5 # who mentioned me
|
|
75
|
+
fanfou +post "今天天气不错" # post a status (≤140 chars)
|
|
76
|
+
fanfou +reply <status-id> "说得对" # reply (auto-prepends @name)
|
|
77
|
+
fanfou +repost <status-id> # repost ("转@用户 原文" if no text)
|
|
78
|
+
fanfou status delete <status-id> # delete your own status
|
|
79
|
+
fanfou +fav <status-id> # favorite
|
|
80
|
+
fanfou user show <login-id> # someone's profile
|
|
81
|
+
fanfou user follow <login-id> # follow
|
|
82
|
+
fanfou +search "关键词" # search public statuses
|
|
83
|
+
fanfou +dm <login-id> "在吗" # send a direct message
|
|
84
|
+
fanfou api GET statuses/home_timeline.json --query count=3 # raw endpoint
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Output formats
|
|
88
|
+
|
|
89
|
+
`--format` / `-o`: `json` (default), `ndjson` (one object per line; good for
|
|
90
|
+
piping/streaming arrays), `table` (compact human summary), `raw` (response text
|
|
91
|
+
as-is). Example: `fanfou +timeline --format table`.
|
|
92
|
+
|
|
93
|
+
## Important behaviors & gotchas
|
|
94
|
+
|
|
95
|
+
- **JSON by default.** Parse stdout as JSON. On error, read the JSON on stderr and
|
|
96
|
+
check the exit code (`2` usage, `3` auth-required, `1` other/HTTP).
|
|
97
|
+
- **Preview before writing.** Every state-changing command supports `--dry-run`
|
|
98
|
+
(`-n`): it prints the exact signed request `{method,url,form,...}` without
|
|
99
|
+
sending it. Use this to confirm a destructive call (e.g. `status delete`) first.
|
|
100
|
+
- **Status IDs can start with `-`** (e.g. `-A_ycI00_Kc`). The parser handles this,
|
|
101
|
+
but if you ever build args dynamically and hit trouble, use `--` to end flag
|
|
102
|
+
parsing: `fanfou status delete -- -A_ycI00_Kc`.
|
|
103
|
+
- **140-character limit** on statuses; longer text is rejected by the server.
|
|
104
|
+
- **`id` means loginname**, not the numeric/`rawid`. Pass the string `id` field.
|
|
105
|
+
- **Destructive ops** (`status delete`, `dm delete`, `user block`, `unfollow`)
|
|
106
|
+
act on the live account — confirm intent, and prefer `--dry-run` to preview.
|
|
107
|
+
- **Reading is safe**; writing posts to the real account. Don't post test/spam
|
|
108
|
+
content to a user's real account without being asked.
|
|
109
|
+
|
|
110
|
+
## Endpoint coverage map (resource command → Fanfou API)
|
|
111
|
+
|
|
112
|
+
| Command | API |
|
|
113
|
+
| --- | --- |
|
|
114
|
+
| `timeline home/public/user/mentions/context/photos` | `statuses/*_timeline`, `statuses/context_timeline`, `photos/user_timeline` |
|
|
115
|
+
| `status show/post/reply/repost/delete/photo` | `statuses/show`, `statuses/update`, `statuses/destroy`, `photos/upload` |
|
|
116
|
+
| `favorite list/add/remove` | `favorites`, `favorites/create/<id>`, `favorites/destroy/<id>` |
|
|
117
|
+
| `user show/friends/followers/follow/unfollow/search/block/unblock/blocks/blocked` | `users/*`, `friendships/create|destroy`, `search/users`, `blocks/*` |
|
|
118
|
+
| `friendship requests/accept/deny/exists` | `friendships/requests|accept|deny|exists` |
|
|
119
|
+
| `dm list/thread/inbox/sent/send/delete` | `direct_messages/*` |
|
|
120
|
+
| `account verify/notification/update-profile/update-avatar` | `account/*` |
|
|
121
|
+
| `search statuses/users` | `search/public_timeline`, `search/users` |
|
|
122
|
+
| anything else | `fanfou api <METHOD> <path>` |
|