-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy patheBooksOpenAPIServer.py
More file actions
executable file
·351 lines (310 loc) · 10.1 KB
/
eBooksOpenAPIServer.py
File metadata and controls
executable file
·351 lines (310 loc) · 10.1 KB
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
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
#!/usr/bin/env python3
# -*- coding:utf-8 -*-
from common import *
from crawler import c_douban_info
app = Bottle()
_API_VERSION = '1.0'
JWT_SECRET_KEY = rd.get('JWT_SECRET_KEY')
# 预处理请求
_REQUEST_IDS = {}
@app.hook('before_request')
def setup_request():
request_id = uuid4().hex
_REQUEST_IDS[threading.get_ident()] = request_id # 线程 ID 与 request_id 的映射
headers_to_set = {
'Content-Type': 'application/json; charset=UTF-8',
'Cache-Control': 'no-cache, no-store, must-revalidate, private, max-age=0',
'Pragma': 'no-cache',
'Expires': '0',
'X-Request-ID': request_id
}
for key, value in headers_to_set.items():
response.set_header(key, value)
return
# 保存请求日志,记录消耗的配额
@app.hook('after_request')
def teardown_request():
request_id = _REQUEST_IDS.get(threading.get_ident())
api_key = request.query.api_key or ''
bearer_token = request.headers.get('Authorization', '').replace('Bearer ', '')
api_key = api_key or bearer_token
# api_key 是必需的,如果没有则不记录日志
if not api_key:
return
ip = request.get_header('Cf-Connecting-Ip', '') or request.remote_addr
request_url = request.urlparts._replace(query=None).geturl()
request_headers = json.dumps(dict(request.headers)).decode('utf-8')
status_code = response.status_code
result = getattr(request, 'result', b'').decode('utf-8')
conn = db_pool.getconn()
conn.autocommit = False
cur = conn.cursor()
try:
q = f'''
INSERT INTO openapi_log (request_id, api_key, ip, request_url, request_headers, status_code, result)
VALUES ('{request_id}', '{api_key}', '{ip}', '{request_url}', '{request_headers}', {status_code}, '{result}')
ON CONFLICT (request_id) DO NOTHING;
'''
cur.execute(q)
conn.commit()
except Exception as e:
traceback.print_exc()
conn.rollback()
try:
q = f'''
WITH t1 AS (
SELECT api_key, COUNT(*) AS cnt
FROM openapi_log
WHERE api_key = '{api_key}'
AND DATE_TRUNC('month', receive_time) = DATE_TRUNC('month', CURRENT_TIMESTAMP)
AND result <> ''
GROUP BY api_key
), t2 AS (
SELECT u.api_key, COALESCE(t1.cnt, 0) AS usage
FROM openapi_user u
LEFT JOIN t1 USING(api_key)
WHERE u.api_key = '{api_key}'
)
UPDATE openapi_user
SET credit = quota - t2.usage, usage = t2.usage
FROM t2
WHERE openapi_user.api_key = t2.api_key;
'''
cur.execute(q)
conn.commit()
except Exception as e:
traceback.print_exc()
conn.rollback()
cur.close()
db_pool.putconn(conn)
return
# 异常处理
@app.error(400)
@app.error(401)
@app.error(403)
@app.error(404)
@app.error(500)
def error_handler(error):
request_id = _REQUEST_IDS.get(threading.get_ident())
code = error.status_code
errmsg = error.body
data = {
'data': {},
'code': code,
'errmsg': errmsg,
'request_id': request_id
}
response.set_header('Content-Type', 'application/json; charset=UTF-8')
return json.dumps(data)
@app.route('/favicon.ico')
def favicon():
return static_file('favicon.ico', root='static')
@app.route('/')
def home():
response.status = 302
response.set_header('Location', 'https://github.com/caspartse/eBooksAssistant?utm_source=openapi.youdianzishu.com&utm_medium=redirect')
def query_jwt_token(api_key: str) -> str:
'''
查询 JWT token
:param api_key: API key, 'eba-[32 位字符串]'
:return: JWT token
'''
conn = db_pool.getconn()
conn.autocommit = False
cur = conn.cursor()
q = f'''
SELECT jwt_token
FROM openapi_token t
WHERE api_key = '{api_key}'
AND is_valid = TRUE
AND EXISTS (
SELECT 1
FROM openapi_user u
WHERE u.is_blocked = FALSE
AND u.api_key = t.api_key
);
'''
cur.execute(q)
jwt_token = cur.fetchone()
cur.close()
db_pool.putconn(conn)
jwt_token = jwt_token[0] if jwt_token else ''
return jwt_token
def verify_jwt_token(jwt_token: str) -> int:
'''
验证 JWT token
:param jwt_token: JWT token
:return: 200: 正常, -1: 过期, -2: 无效
'''
try:
jwt.decode(jwt_token, JWT_SECRET_KEY, algorithms=['HS256'])
return 200
except jwt.ExpiredSignatureError:
return -1
except jwt.InvalidTokenError:
return -2
def query_credit(api_key: str) -> int:
'''
查询 API key 的可用额度 (剩余配额)
:param api_key: API key
:return: 可用额度
'''
conn = db_pool.getconn()
conn.autocommit = False
cur = conn.cursor()
q = f'''
SELECT credit
FROM openapi_user
WHERE api_key = '{api_key}'
'''
cur.execute(q)
row = cur.fetchone()
cur.close()
db_pool.putconn(conn)
credit = row[0] if row else 0
return credit
def query_metadata(isbn: str) -> dict:
'''
查询元数据
:param isbn: ISBN
:return: 元数据字典
'''
metadata = {}
conn = db_pool.getconn()
conn.autocommit = False
cur = conn.cursor()
q = f'''
SELECT
md.isbn,
md.douban_rating_score AS douban_rating,
md.douban_url,
mk.url AS weread_url,
md.title,
md.author,
md.publisher,
md.producer,
md.subtitle,
md.original_title,
md.translator,
md.published,
md.pages,
md.price,
md.binding,
md.series,
md.description AS douban_intro,
mk.description AS weread_intro,
md.cover_url
FROM metadata md
LEFT JOIN market mk
ON md.isbn = mk.isbn
AND mk.vendor = 'weread'
AND mk.display_isbn = '{isbn}'
AND mk.description <> ''
WHERE md.isbn = '{isbn}'
'''
# print(q)
try:
cur.execute(q)
meta_tuple = namedtuple('MetaTuple', [desc[0] for desc in cur.description]) # 获取字段名
metadata = cur.fetchone()
if metadata:
metadata = meta_tuple(*metadata)._asdict() # 转换为字典
except Exception as e:
traceback.print_exc()
finally:
cur.close()
db_pool.putconn(conn)
return metadata
def metadata_beautify(metadata: dict) -> dict:
'''
元数据内容调整
:param metadata: 元数据字典
:return: 调整后的元数据字典
'''
# 调整字段名
if 'douban_intro' not in metadata:
metadata['douban_intro'] = metadata.get('description', '')
metadata.pop('description', None)
if 'douban_rating' not in metadata:
metadata['douban_rating'] = metadata.get('douban_rating_score', 0.0)
metadata.pop('douban_rating_score', None)
if 'weread_url' not in metadata:
metadata['weread_url'] = ''
if 'weread_intro' not in metadata:
metadata['weread_intro'] = ''
metadata.pop('douban_rating_star', None)
# 豆瓣评分转换成小数, 保留一位小数
try:
metadata['douban_rating'] = round(float(metadata['douban_rating']) * 1.0, 1)
except Exception as e:
metadata['douban_rating'] = 0.0
# douban_intro 移除换行符
metadata['douban_intro'] = metadata['douban_intro'].replace('\n', '')
# 页数转换成整数
try:
metadata['pages'] = int(metadata['pages'])
except Exception as e:
metadata['pages'] = 0
# 设置图片代理, 用于解决图片防盗链问题
if metadata['cover_url']:
cover_url_proxy_weserv = f'https://images.weserv.nl/?url={metadata["cover_url"]}'
metadata['cover_url_proxy_weserv'] = cover_url_proxy_weserv
cover_url_proxy_baidu = f'https://image.baidu.com/search/down?url={metadata["cover_url"]}'
metadata['cover_url_proxy_baidu'] = cover_url_proxy_baidu
else:
metadata['cover_url_proxy_weserv'] = ''
metadata['cover_url_proxy_baidu'] = ''
return metadata
@app.route('/metadata', method='GET')
def metadata_main() -> str:
'''
获取元数据
:return: json 字符串
'''
request_id = _REQUEST_IDS.get(threading.get_ident())
result = {
'data': {},
'code': 200,
'errmsg': '',
'request_id': request_id,
'credit': 0,
'api_version': _API_VERSION
}
api_key = request.query.api_key or ''
isbn = request.query.isbn or ''
bearer_token = request.headers.get('Authorization', '').replace('Bearer ', '')
api_key = api_key or bearer_token
# 参数检查
if not isbn:
abort(400, 'ISBN is required.')
if not api_key:
abort(401, 'API key is required.')
if not re.match(r'^eba-[0-9a-f]{32}$', api_key):
abort(401, 'Invalid API key: Worng format.')
# 验证 API key
jwt_token = query_jwt_token(api_key)
if not jwt_token:
abort(401, 'Invalid API key: Record not found.')
jwt_token_status = verify_jwt_token(jwt_token)
if jwt_token_status == -1:
abort(401, 'Expired API key.')
elif jwt_token_status == -2:
abort(401, 'Invalid API key: Signature verification failed.')
# 检查可用额度
credit = query_credit(api_key)
if credit <= 0:
abort(403, 'Exceed the quota.')
result['credit'] = credit - 1
metadata = query_metadata(isbn)
essential_fields = ['title', 'author', 'publisher', 'douban_intro', 'cover_url', 'douban_rating', 'douban_url']
if not metadata or any(not metadata.get(field) for field in essential_fields):
metadata = c_douban_info(isbn)
if not metadata:
abort(404, 'Not found.')
metadata = metadata_beautify(metadata)
result['data'] = metadata
resp = json.dumps(result)
request.result = resp # 保存结果
return resp
if __name__ == '__main__':
run(app, server='paste', host='0.0.0.0', port=8088, debug=True, reloader=True)