背景

有一个需求是通过代码对接一个格式转换的开源项目用于格式转换,最后选定了ConvertX这个项目,但是发现没有提供直接可用的API接口,只有WEB端的接口,最后通过分析WEB端的接口请求,成功对接了ConvertX的接口。

ConvertX接口分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
async ({ jwt, redirect, cookie: { auth, jobId } }) => {
if (!ALLOW_UNAUTHENTICATED) {
if (FIRST_RUN) {
return redirect(`${WEBROOT}/setup`, 302);
}

if (!auth?.value) {
return redirect(`${WEBROOT}/login`, 302);
}
}

// validate jwt
let user: ({ id: string } & JWTPayloadSpec) | false = false;
if (ALLOW_UNAUTHENTICATED) {
const newUserId = String(
UNAUTHENTICATED_USER_SHARING
? 0
: randomInt(2 ** 24, Math.min(2 ** 48 + 2 ** 24 - 1, Number.MAX_SAFE_INTEGER)),
);
const accessToken = await jwt.sign({
id: newUserId,
});

user = { id: newUserId };
if (!auth) {
return {
message: "No auth cookie, perhaps your browser is blocking cookies.",
};
}

// set cookie
auth.set({
value: accessToken,
httpOnly: true,
secure: !HTTP_ALLOWED,
maxAge: 24 * 60 * 60,
sameSite: "strict",
});
} else if (auth?.value) {
user = await jwt.verify(auth.value);

if (
user !== false &&
user.id &&
(Number.parseInt(user.id) < 2 ** 24 || !ALLOW_UNAUTHENTICATED)
) {
// Make sure user exists in db
const existingUser = db.query("SELECT * FROM users WHERE id = ?").as(User).get(user.id);

if (!existingUser) {
if (auth?.value) {
auth.remove();
}
return redirect(`${WEBROOT}/login`, 302);
}
}
}

if (!user) {
return redirect(`${WEBROOT}/login`, 302);
}

// create a new job
db.query("INSERT INTO jobs (user_id, date_created) VALUES (?, ?)").run(
user.id,
new Date().toISOString(),
);

const { id } = db
.query("SELECT id FROM jobs WHERE user_id = ? ORDER BY id DESC")
.get(user.id) as { id: number };

if (!jobId) {
return { message: "Cookies should be enabled to use this app." };
}

jobId.set({
value: id,
httpOnly: true,
secure: !HTTP_ALLOWED,
maxAge: 24 * 60 * 60,
sameSite: "strict",
});

console.log("jobId set to:", id);

cookie管理的代码如上述,在用户访问时会检查是否有认证cookie,如果没有则会重定向到登录页面,如果有则会验证JWT并在数据库中创建一个新的转换任务,并将任务ID存储在cookie中。
因此,虽然可以在客户端手动生成JWT并设置cookie来模拟登录状态,但更推荐的方式是直接调用后端接口来获取JWT和任务ID,以确保与ConvertX的任务管理兼容。

接口介绍

开启授权的情况下通过登录接口获取cookie

输入:
x-www-form-urlencoded格式的请求体,包含以下字段:

  • email: 用户的邮箱地址
  • password: 用户的密码

输出:
接口会返回一个set-cookie头,包含认证cookie和任务ID cookie。

1
2
set-cookie	auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjAiLCJleHAiOjE3NzEyOTY2OTYsImlhdCI6MTc3MDY5MTg5Nn0.AnraVgNbtmD4qHENEZNokc2N-kg4_K0bRXJUsSauULw; Max-Age=86400; Path=/; HttpOnly; SameSite=Strict
set-cookie jobId=1; Max-Age=86400; Path=/; HttpOnly; SameSite=Strict
1
2
3
curl --location --request POST 'http://127.0.0.1:4523/m1/7789919-7536610-default/login' \
--data-urlencode 'email=aaa' \
--data-urlencode 'password=bbb'

未授权的情况下直接获取cookie

在关闭鉴权,比如用容器部署时将ALLOW_UNAUTHENTICATED设置为false时,访问根路径会直接返回一个新的JWT和任务ID,并设置在cookie中,内容同登录接口。

1
2
set-cookie	auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjAiLCJleHAiOjE3NzEyOTY2OTYsImlhdCI6MTc3MDY5MTg5Nn0.AnraVgNbtmD4qHENEZNokc2N-kg4_K0bRXJUsSauULw; Max-Age=86400; Path=/; HttpOnly; SameSite=Strict
set-cookie jobId=1; Max-Age=86400; Path=/; HttpOnly; SameSite=Strict
1
curl --location --request GET 'http://127.0.0.1:4523/m1/7789919-7536610-default/'

文件上传

form-data格式的请求体,包含以下字段:

  • file: 要上传的文件,使用文件上传的方式发送,字段名为file
1
2
curl --location --request POST 'http://127.0.0.1:4523/m1/7789919-7536610-default/upload' \
--form 'file=@"D:\\Downloads\\12月12日.mp4"'

获取可用的转换策略

json格式的请求体,包含以下字段:

  • fileType: 要转换的目标文件类型,例如docx、pdf等

输出:接口会返回一个html,会被渲染成一个包含可用转换策略的页面,用户可以在页面上选择转换策略并开始转换。
这个地方可以用正则提取出来需要的转换策略信息。可以选择(?<=data-value=")[^"]+或者(?<=option value=")[^"]+

1
2
3
4
5
6
curl --location --request POST 'http://127.0.0.1:4523/m1/7789919-7536610-default/conversions' \
--header 'Content-Type: application/json' \
--data-raw '{
"fileType": "docx"
}'

样例数据:
最后用正则提取出来的数据为(目标格式,转换工具)的列表

1
2
3
4
5
6
azw3,calibre
docx,calibre
epub,calibre
fb2,calibre
html,calibre
htmlz,calibre

开始转换

json格式的请求体,包含以下字段:

  • file_names: 要转换的文件名列表,格式为JSON字符串,例如[“文件名”]
  • convert_to: 转换策略,也就是上一步提取出来的转换工具和目标格式的组合,例如pdf,calibre
1
2
3
curl --location --request POST 'http://127.0.0.1:4523/m1/7789919-7536610-default/convert' \
--data-urlencode 'file_names=["文件名"]' \
--data-urlencode 'convert_to=pdf,calibre'

获取转换进度和最终结果

此处接口设计有点奇怪,是一个没有body的POST请求,在url中需要使用path 参数传入cookie中存储的任务ID。

输出:
此处需要注意一下

  1. 这个接口每次调用会返回一个html页面,页面渲染的时候会展示转换进度,如果有需要可以用正则提取出来。
  2. 直到文件转换完成后,接口才会返回最终结果的下载链接,因此需要轮询调用这个接口来获取转换进度和最终结果,最终结果可以用正则(?<=href=")/download/[^"]+(?="\s+download=)提取
1
curl --location --request POST 'http://127.0.0.1:4523/m1/7789919-7536610-default/progress/{id}'

下载文件

下载接口有鉴权,所以需要带上cookie

  • download_path: 从上一个接口提取出来的下载链接中的路径部分,例如/download/0/1/xx.pdf
1
curl --location --request GET 'http://127.0.0.1:4523/m1/7789919-7536610-default/{download_path}'

获取最终结果

此处可以获取任务的最终结果,同样是path 参数传入任务ID,接口会返回一个html页面,页面中包含最终结果的下载链接,可以用正则提取出来。
这个接口用处不大,因为转换完成后,进度接口就会返回最终结果的下载链接了。

1
curl --location --request GET 'http://127.0.0.1:4523/m1/7789919-7536610-default/results/'

OkHttpClient中如何管理cookie

CookieJar接口是OkHttpClient中用于管理cookie的接口,可以简单实现这个接口,直接将cookie存储在内存中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static class InMemoryCookieJar implements CookieJar {
@Getter
private final Map<String, List<Cookie>> cookieStore = new HashMap<>();

@Override
public void saveFromResponse(HttpUrl url, List<Cookie> cookies) {
cookieStore.put(url.host(), cookies);
}

@Override
public List<Cookie> loadForRequest(HttpUrl url) {
return cookieStore.getOrDefault(url.host(), new ArrayList<>());
}
}

使用方式

1
2
3
OkHttpClient client = new OkHttpClient.Builder()
.cookieJar(new InMemoryCookieJar())
.build();