gengwiki 0.1.0__tar.gz
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.
- gengwiki-0.1.0/LICENSE +21 -0
- gengwiki-0.1.0/PKG-INFO +103 -0
- gengwiki-0.1.0/README.md +77 -0
- gengwiki-0.1.0/pyproject.toml +45 -0
- gengwiki-0.1.0/pyproject.toml.orig +36 -0
- gengwiki-0.1.0/src/gengwiki/__init__.py +55 -0
- gengwiki-0.1.0/src/gengwiki/__main__.py +179 -0
- gengwiki-0.1.0/src/gengwiki/_http.py +136 -0
- gengwiki-0.1.0/src/gengwiki/_keyring.py +40 -0
- gengwiki-0.1.0/src/gengwiki/_search.py +54 -0
- gengwiki-0.1.0/src/gengwiki/_store.py +76 -0
- gengwiki-0.1.0/src/gengwiki/client.py +414 -0
- gengwiki-0.1.0/src/gengwiki/errors.py +55 -0
- gengwiki-0.1.0/src/gengwiki/keys.py +247 -0
- gengwiki-0.1.0/src/gengwiki/models.py +149 -0
- gengwiki-0.1.0/src/gengwiki/py.typed +0 -0
- gengwiki-0.1.0/src/gengwiki/tools.py +167 -0
gengwiki-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zhixiang Feng
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
gengwiki-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gengwiki
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for gengwiki, local first with the API as a fallback
|
|
5
|
+
Keywords: gengwiki,meme,sdk,llm,function-calling
|
|
6
|
+
Author: Zhixiang Feng
|
|
7
|
+
Author-email: Zhixiang Feng <contact@zhixiangfeng.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Natural Language :: Chinese (Simplified)
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Project-URL: Homepage, https://gengwiki.niox.lol
|
|
23
|
+
Project-URL: Documentation, https://gengwiki.niox.lol/docs
|
|
24
|
+
Project-URL: Repository, https://github.com/Niox1337/gengwiki-python
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# gengwiki-python
|
|
28
|
+
|
|
29
|
+
梗Wiki 的 Python SDK,支持 Python 3.10 及以上。
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install gengwiki
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
到[个人中心](https://gengwiki.niox.lol/me)创建 Key,然后保存到本机,之后的代码里就不用再写它了。
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
gengwiki keys add 老哥 # 粘贴时不显示,也不会留在命令历史里
|
|
39
|
+
gengwiki status # 查看当前 Key、人设和额度
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from gengwiki import GengWiki
|
|
44
|
+
|
|
45
|
+
client = GengWiki() # 也可以传入 Key,或者设置环境变量 GENGWIKI_API_KEY
|
|
46
|
+
|
|
47
|
+
client.search_memes("躺平")
|
|
48
|
+
client.meme("tang-ping").body
|
|
49
|
+
client.persona().as_message() # {"role": "system", "content": "..."}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
更多用法见 [example/](https://github.com/Niox1337/gengwiki-python/tree/main/example)。
|
|
53
|
+
|
|
54
|
+
## 切换人设
|
|
55
|
+
|
|
56
|
+
一个 Key 终身绑定一个人设,所以切换人设就是切换到绑定了它的 Key。没有这样的 Key 时,登录后会自动创建一个并保存。
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
persona = client.use_persona("温柔姐姐") # 按标题或 id
|
|
60
|
+
messages = [persona.as_message(), {"role": "user", "content": "你好"}]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`client.personas("关键词")` 可以浏览人设目录。Claude 的系统提示不放在消息列表里,所以要写成 `system=persona.prompt`。
|
|
64
|
+
|
|
65
|
+
## 命令行
|
|
66
|
+
|
|
67
|
+
安装后可以用 `gengwiki`,也可以用 `python -m gengwiki`。
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
gengwiki login # 登录,密码不会保存
|
|
71
|
+
gengwiki status # 当前站点、Key、人设和额度
|
|
72
|
+
gengwiki search 躺平 # 也有 meme、trending、personas
|
|
73
|
+
gengwiki use 温柔姐姐 # 切换人设
|
|
74
|
+
gengwiki sync --images # 下载全部内容供离线使用
|
|
75
|
+
gengwiki keys # 也有 add、use、remove、secret、remote、rotate、revoke
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## 给大模型用的工具
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
tools = client.tools.openai() # OpenAI 和 DeepSeek 等的 Chat Completions
|
|
82
|
+
tools = client.tools.responses() # OpenAI 的 Responses API
|
|
83
|
+
tools = client.tools.anthropic() # Claude
|
|
84
|
+
|
|
85
|
+
while True:
|
|
86
|
+
reply = llm.chat.completions.create(model=model, messages=messages, tools=tools).choices[0].message
|
|
87
|
+
results = client.tools.run(reply)
|
|
88
|
+
if not results:
|
|
89
|
+
break
|
|
90
|
+
messages += [reply, *results]
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Claude 的回复用法相同,只是追加时写成 `{"role": "assistant", "content": reply.content}`。Responses API 把 `response` 传给 `run`,追加时写成 `input += [*response.output, *results]`。
|
|
94
|
+
|
|
95
|
+
共有 5 个工具,分别是 `search_memes`、`get_meme`、`trending_memes`、`search_stickers` 和 `search_lexicon`。可以用 `client.tools.openai(["search_memes"])` 只选其中几个。
|
|
96
|
+
|
|
97
|
+
## 开发
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
uv run python -m unittest discover -s tests -t .
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`tests/test_live.py` 用来对照真实服务,只有设置了 `GENGWIKI_LIVE_API` 和 `GENGWIKI_LIVE_SITE` 才会运行,需要指向一套载入了 `tests/live_seed.sql` 的临时环境,绝对不要指向生产环境。
|
gengwiki-0.1.0/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# gengwiki-python
|
|
2
|
+
|
|
3
|
+
梗Wiki 的 Python SDK,支持 Python 3.10 及以上。
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install gengwiki
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
到[个人中心](https://gengwiki.niox.lol/me)创建 Key,然后保存到本机,之后的代码里就不用再写它了。
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
gengwiki keys add 老哥 # 粘贴时不显示,也不会留在命令历史里
|
|
13
|
+
gengwiki status # 查看当前 Key、人设和额度
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from gengwiki import GengWiki
|
|
18
|
+
|
|
19
|
+
client = GengWiki() # 也可以传入 Key,或者设置环境变量 GENGWIKI_API_KEY
|
|
20
|
+
|
|
21
|
+
client.search_memes("躺平")
|
|
22
|
+
client.meme("tang-ping").body
|
|
23
|
+
client.persona().as_message() # {"role": "system", "content": "..."}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
更多用法见 [example/](https://github.com/Niox1337/gengwiki-python/tree/main/example)。
|
|
27
|
+
|
|
28
|
+
## 切换人设
|
|
29
|
+
|
|
30
|
+
一个 Key 终身绑定一个人设,所以切换人设就是切换到绑定了它的 Key。没有这样的 Key 时,登录后会自动创建一个并保存。
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
persona = client.use_persona("温柔姐姐") # 按标题或 id
|
|
34
|
+
messages = [persona.as_message(), {"role": "user", "content": "你好"}]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`client.personas("关键词")` 可以浏览人设目录。Claude 的系统提示不放在消息列表里,所以要写成 `system=persona.prompt`。
|
|
38
|
+
|
|
39
|
+
## 命令行
|
|
40
|
+
|
|
41
|
+
安装后可以用 `gengwiki`,也可以用 `python -m gengwiki`。
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
gengwiki login # 登录,密码不会保存
|
|
45
|
+
gengwiki status # 当前站点、Key、人设和额度
|
|
46
|
+
gengwiki search 躺平 # 也有 meme、trending、personas
|
|
47
|
+
gengwiki use 温柔姐姐 # 切换人设
|
|
48
|
+
gengwiki sync --images # 下载全部内容供离线使用
|
|
49
|
+
gengwiki keys # 也有 add、use、remove、secret、remote、rotate、revoke
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 给大模型用的工具
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
tools = client.tools.openai() # OpenAI 和 DeepSeek 等的 Chat Completions
|
|
56
|
+
tools = client.tools.responses() # OpenAI 的 Responses API
|
|
57
|
+
tools = client.tools.anthropic() # Claude
|
|
58
|
+
|
|
59
|
+
while True:
|
|
60
|
+
reply = llm.chat.completions.create(model=model, messages=messages, tools=tools).choices[0].message
|
|
61
|
+
results = client.tools.run(reply)
|
|
62
|
+
if not results:
|
|
63
|
+
break
|
|
64
|
+
messages += [reply, *results]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Claude 的回复用法相同,只是追加时写成 `{"role": "assistant", "content": reply.content}`。Responses API 把 `response` 传给 `run`,追加时写成 `input += [*response.output, *results]`。
|
|
68
|
+
|
|
69
|
+
共有 5 个工具,分别是 `search_memes`、`get_meme`、`trending_memes`、`search_stickers` 和 `search_lexicon`。可以用 `client.tools.openai(["search_memes"])` 只选其中几个。
|
|
70
|
+
|
|
71
|
+
## 开发
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uv run python -m unittest discover -s tests -t .
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`tests/test_live.py` 用来对照真实服务,只有设置了 `GENGWIKI_LIVE_API` 和 `GENGWIKI_LIVE_SITE` 才会运行,需要指向一套载入了 `tests/live_seed.sql` 的临时环境,绝对不要指向生产环境。
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "gengwiki"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python SDK for gengwiki, local first with the API as a fallback"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"gengwiki",
|
|
11
|
+
"meme",
|
|
12
|
+
"sdk",
|
|
13
|
+
"llm",
|
|
14
|
+
"function-calling",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Natural Language :: Chinese (Simplified)",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[[project.authors]]
|
|
32
|
+
name = "Zhixiang Feng"
|
|
33
|
+
email = "contact@zhixiangfeng.com"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://gengwiki.niox.lol"
|
|
37
|
+
Documentation = "https://gengwiki.niox.lol/docs"
|
|
38
|
+
Repository = "https://github.com/Niox1337/gengwiki-python"
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
gengwiki = "gengwiki.__main__:main"
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["uv_build>=0.8,<1"]
|
|
45
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "gengwiki"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python SDK for gengwiki, local first with the API as a fallback"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Zhixiang Feng", email = "contact@zhixiangfeng.com" }]
|
|
10
|
+
keywords = ["gengwiki", "meme", "sdk", "llm", "function-calling"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Natural Language :: Chinese (Simplified)",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://gengwiki.niox.lol"
|
|
28
|
+
Documentation = "https://gengwiki.niox.lol/docs"
|
|
29
|
+
Repository = "https://github.com/Niox1337/gengwiki-python"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
gengwiki = "gengwiki.__main__:main"
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["uv_build>=0.8,<1"]
|
|
36
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
from .client import GengWiki # noqa: E402
|
|
4
|
+
from .errors import ( # noqa: E402
|
|
5
|
+
AccountApiUnavailable,
|
|
6
|
+
AuthError,
|
|
7
|
+
BadRequestError,
|
|
8
|
+
BadResponseError,
|
|
9
|
+
ConflictError,
|
|
10
|
+
ForbiddenError,
|
|
11
|
+
GengWikiError,
|
|
12
|
+
NetworkError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
ServerError,
|
|
16
|
+
UnavailableError,
|
|
17
|
+
)
|
|
18
|
+
from .models import ( # noqa: E402
|
|
19
|
+
ApiKey,
|
|
20
|
+
Lexicon,
|
|
21
|
+
LexiconEntry,
|
|
22
|
+
LocalKey,
|
|
23
|
+
Meme,
|
|
24
|
+
MemeSummary,
|
|
25
|
+
Persona,
|
|
26
|
+
RateLimit,
|
|
27
|
+
Sticker,
|
|
28
|
+
StickerPack,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"AccountApiUnavailable",
|
|
33
|
+
"ApiKey",
|
|
34
|
+
"AuthError",
|
|
35
|
+
"BadRequestError",
|
|
36
|
+
"BadResponseError",
|
|
37
|
+
"ConflictError",
|
|
38
|
+
"ForbiddenError",
|
|
39
|
+
"GengWiki",
|
|
40
|
+
"GengWikiError",
|
|
41
|
+
"Lexicon",
|
|
42
|
+
"LexiconEntry",
|
|
43
|
+
"LocalKey",
|
|
44
|
+
"Meme",
|
|
45
|
+
"MemeSummary",
|
|
46
|
+
"NetworkError",
|
|
47
|
+
"NotFoundError",
|
|
48
|
+
"Persona",
|
|
49
|
+
"RateLimit",
|
|
50
|
+
"RateLimitError",
|
|
51
|
+
"ServerError",
|
|
52
|
+
"Sticker",
|
|
53
|
+
"StickerPack",
|
|
54
|
+
"UnavailableError",
|
|
55
|
+
]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""The command line. Mostly for first-time setup, so a password or key never has to sit in a script or shell history."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import getpass
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import GengWiki, GengWikiError, __version__
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _memes(rows) -> None:
|
|
11
|
+
if not rows:
|
|
12
|
+
print("没有找到")
|
|
13
|
+
for m in rows:
|
|
14
|
+
print(f"{m.title} [{m.slug}] {m.summary}")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _login(c: GengWiki, a) -> None:
|
|
18
|
+
user = c.keys.login(a.email or input("邮箱 "), getpass.getpass("密码 "))
|
|
19
|
+
print(f"已登录 {user.get('email', '')},7 天内有效,密码没有保存")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _logout(c: GengWiki, a) -> None:
|
|
23
|
+
c.keys.logout()
|
|
24
|
+
print("已退出")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _status(c: GengWiki, a) -> None:
|
|
28
|
+
print(f"站点 {c.base_url}")
|
|
29
|
+
print(f"登录 {'已登录' if c.keys.logged_in else '未登录'}")
|
|
30
|
+
if not c._key:
|
|
31
|
+
print(f"Key 没有,到 {c.site_url}/me 创建后运行 gengwiki keys add 名字")
|
|
32
|
+
return
|
|
33
|
+
saved = next((k for k in c.keys.local() if k.current), None)
|
|
34
|
+
print(f"Key {saved.name if saved else '来自参数或环境变量'} {c._key[:15]}")
|
|
35
|
+
persona = c.persona()
|
|
36
|
+
print(f"人设 {persona.title if persona else '没有'}")
|
|
37
|
+
try:
|
|
38
|
+
r = c.rate_limit()
|
|
39
|
+
print(f"额度 今天 {r.used_today}/{r.per_day},这一分钟 {r.used_this_minute}/{r.per_minute}")
|
|
40
|
+
except GengWikiError as e:
|
|
41
|
+
print(f"额度 暂时查不到,{e.message}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _keys_local(c: GengWiki, a) -> None:
|
|
45
|
+
rows = c.keys.local()
|
|
46
|
+
if not rows:
|
|
47
|
+
print(f"还没有保存 Key,到 {c.site_url}/me 创建后运行 gengwiki keys add 名字")
|
|
48
|
+
for k in rows:
|
|
49
|
+
print(f"{'*' if k.current else ' '} {k.name} {k.prefix} {k.persona_title or ''}".rstrip())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _keys_add(c: GengWiki, a) -> None:
|
|
53
|
+
print(f"已保存 {c.keys.add(a.name, getpass.getpass('粘贴 Key ').strip()).name}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _keys_use(c: GengWiki, a) -> None:
|
|
57
|
+
print(f"已切换到 {c.keys.use(a.name).name}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _keys_remove(c: GengWiki, a) -> None:
|
|
61
|
+
c.keys.remove(a.name)
|
|
62
|
+
print(f"已从本机删除 {a.name},服务器上依然有效")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _keys_secret(c: GengWiki, a) -> None:
|
|
66
|
+
print(c.keys.secret(a.name))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _keys_remote(c: GengWiki, a) -> None:
|
|
70
|
+
for k in c.keys.list():
|
|
71
|
+
print(f"{k.name} {k.prefix} 最后使用 {k.last_used_at or '从未'}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _keys_rotate(c: GengWiki, a) -> None:
|
|
75
|
+
print(f"已换新密钥并保存到 {c.keys.rotate(a.name).name},旧密钥立即失效")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _keys_revoke(c: GengWiki, a) -> None:
|
|
79
|
+
c.keys.revoke(a.name)
|
|
80
|
+
print(f"已吊销 {a.name}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _personas(c: GengWiki, a) -> None:
|
|
84
|
+
rows = c.personas(a.q)
|
|
85
|
+
if not rows:
|
|
86
|
+
print("没有找到")
|
|
87
|
+
for p in rows:
|
|
88
|
+
print(f"{p.title} {p.description}".rstrip())
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _use(c: GengWiki, a) -> None:
|
|
92
|
+
print(f"已切换到 {c.use_persona(a.persona).title}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _search(c: GengWiki, a) -> None:
|
|
96
|
+
_memes(c.search_memes(a.q))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _trending(c: GengWiki, a) -> None:
|
|
100
|
+
_memes(c.trending(a.tag))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _meme(c: GengWiki, a) -> None:
|
|
104
|
+
m = c.meme(a.slug)
|
|
105
|
+
print(m.title)
|
|
106
|
+
if m.aliases:
|
|
107
|
+
print("别名 " + "、".join(m.aliases))
|
|
108
|
+
print(f"\n{m.body or m.summary}")
|
|
109
|
+
if m.usage_scenario:
|
|
110
|
+
print(f"\n使用场景 {m.usage_scenario}")
|
|
111
|
+
for example in m.usage_examples:
|
|
112
|
+
print(f"\n {example}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _sync(c: GengWiki, a) -> None:
|
|
116
|
+
s = c.sync(images=a.images)
|
|
117
|
+
print(f"梗 {s['memes']} 个,表情 {s['stickers']} 个,图片 {s['images']} 张,发出 {s['requests']} 个请求")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _parser() -> argparse.ArgumentParser:
|
|
121
|
+
parser = argparse.ArgumentParser(prog="gengwiki", description="梗Wiki 命令行")
|
|
122
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
123
|
+
parser.set_defaults(run=None)
|
|
124
|
+
sub = parser.add_subparsers(metavar="命令")
|
|
125
|
+
|
|
126
|
+
def command(parent, name, run, help_text, *args):
|
|
127
|
+
p = parent.add_parser(name, help=help_text)
|
|
128
|
+
p.set_defaults(run=run)
|
|
129
|
+
for arg, kwargs in args:
|
|
130
|
+
p.add_argument(arg, **kwargs)
|
|
131
|
+
return p
|
|
132
|
+
|
|
133
|
+
optional = {"nargs": "?", "default": ""}
|
|
134
|
+
command(sub, "status", _status, "当前站点、Key、人设和额度")
|
|
135
|
+
command(sub, "login", _login, "登录以管理 Key", ("email", optional))
|
|
136
|
+
command(sub, "logout", _logout, "退出登录")
|
|
137
|
+
command(sub, "search", _search, "搜索梗", ("q", optional))
|
|
138
|
+
command(sub, "meme", _meme, "查看一个梗", ("slug", {}))
|
|
139
|
+
command(sub, "trending", _trending, "热门榜", ("tag", optional))
|
|
140
|
+
command(sub, "personas", _personas, "浏览人设", ("q", optional))
|
|
141
|
+
command(sub, "use", _use, "切换人设", ("persona", {}))
|
|
142
|
+
command(sub, "sync", _sync, "下载全部内容供离线使用", ("--images", {"action": "store_true"}))
|
|
143
|
+
|
|
144
|
+
keys = command(sub, "keys", _keys_local, "列出已保存的 Key").add_subparsers(metavar="操作")
|
|
145
|
+
command(keys, "add", _keys_add, "保存在网站上创建的 Key", ("name", {}))
|
|
146
|
+
command(keys, "use", _keys_use, "切换到已保存的 Key", ("name", {}))
|
|
147
|
+
command(keys, "remove", _keys_remove, "只从本机删除", ("name", {}))
|
|
148
|
+
command(keys, "secret", _keys_secret, "输出密钥,交给别的程序", ("name", {}))
|
|
149
|
+
command(keys, "remote", _keys_remote, "列出账号上的 Key,需要登录")
|
|
150
|
+
command(keys, "rotate", _keys_rotate, "换新密钥,需要登录", ("name", {}))
|
|
151
|
+
command(keys, "revoke", _keys_revoke, "吊销并从本机删除,需要登录", ("name", {}))
|
|
152
|
+
return parser
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def main(argv: list[str] | None = None) -> int:
|
|
156
|
+
for stream in (sys.stdout, sys.stderr):
|
|
157
|
+
if hasattr(stream, "reconfigure"):
|
|
158
|
+
# A pipe on a non-Chinese Windows is cp1252, which would turn all the text into question marks.
|
|
159
|
+
# On any encoding, one character it lacks must not end the program.
|
|
160
|
+
encoding = stream.encoding if "梗".encode(stream.encoding, "ignore") else "utf-8"
|
|
161
|
+
stream.reconfigure(encoding=encoding, errors="replace")
|
|
162
|
+
parser = _parser()
|
|
163
|
+
args = parser.parse_args(argv)
|
|
164
|
+
if args.run is None:
|
|
165
|
+
parser.print_help()
|
|
166
|
+
return 0
|
|
167
|
+
try:
|
|
168
|
+
with GengWiki() as client:
|
|
169
|
+
args.run(client, args)
|
|
170
|
+
except (GengWikiError, ValueError) as e:
|
|
171
|
+
print(f"错误 {getattr(e, 'message', e)}", file=sys.stderr)
|
|
172
|
+
return 1
|
|
173
|
+
except (KeyboardInterrupt, EOFError):
|
|
174
|
+
return 130
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
sys.exit(main())
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Stdlib HTTP with typed errors. Knows nothing about caching."""
|
|
2
|
+
|
|
3
|
+
import email.message
|
|
4
|
+
import gzip
|
|
5
|
+
import http.client
|
|
6
|
+
import ipaddress
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
import zlib
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .errors import (
|
|
16
|
+
AuthError,
|
|
17
|
+
BadRequestError,
|
|
18
|
+
BadResponseError,
|
|
19
|
+
ConflictError,
|
|
20
|
+
ForbiddenError,
|
|
21
|
+
GengWikiError,
|
|
22
|
+
NetworkError,
|
|
23
|
+
NotFoundError,
|
|
24
|
+
RateLimitError,
|
|
25
|
+
ServerError,
|
|
26
|
+
UnavailableError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_BY_STATUS = {400: BadRequestError, 401: AuthError, 403: ForbiddenError, 404: NotFoundError, 409: ConflictError}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
33
|
+
"""urllib would forward the Authorization header to wherever a redirect points."""
|
|
34
|
+
|
|
35
|
+
def redirect_request(self, *args, **kwargs):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def check_url(url: str) -> str:
|
|
40
|
+
"""Bearer tokens only travel over HTTPS, plain HTTP is for a dev server on this machine."""
|
|
41
|
+
parts = urllib.parse.urlsplit(url)
|
|
42
|
+
if parts.scheme == "https" and parts.hostname:
|
|
43
|
+
return url.rstrip("/")
|
|
44
|
+
if parts.scheme == "http" and parts.hostname:
|
|
45
|
+
try:
|
|
46
|
+
loopback = ipaddress.ip_address(parts.hostname).is_loopback
|
|
47
|
+
except ValueError:
|
|
48
|
+
loopback = parts.hostname == "localhost"
|
|
49
|
+
if loopback:
|
|
50
|
+
return url.rstrip("/")
|
|
51
|
+
raise ValueError(f"{url!r} must be https, or http on localhost")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _raise_for(status: int, body: bytes, headers: email.message.Message) -> None:
|
|
55
|
+
code, message = None, f"HTTP {status}"
|
|
56
|
+
try:
|
|
57
|
+
err = json.loads(body)["error"]
|
|
58
|
+
code, message = err["code"], err["message"]
|
|
59
|
+
except (ValueError, KeyError, TypeError):
|
|
60
|
+
pass
|
|
61
|
+
if status == 429:
|
|
62
|
+
try:
|
|
63
|
+
retry_after = float(headers.get("Retry-After", 0))
|
|
64
|
+
except ValueError:
|
|
65
|
+
retry_after = 0
|
|
66
|
+
raise RateLimitError(message, retry_after=retry_after)
|
|
67
|
+
if status >= 500:
|
|
68
|
+
raise ServerError(message, status, code)
|
|
69
|
+
raise _BY_STATUS.get(status, GengWikiError)(message, status, code)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Http:
|
|
73
|
+
def __init__(self, timeout: float = 15):
|
|
74
|
+
self.timeout = timeout
|
|
75
|
+
self.count = 0
|
|
76
|
+
self._opener = urllib.request.build_opener(_NoRedirect)
|
|
77
|
+
|
|
78
|
+
# ponytail: urllib opens one connection per request. Requests are rare by design, pool with http.client if that changes.
|
|
79
|
+
def _once(self, req: urllib.request.Request) -> tuple[bytes, email.message.Message]:
|
|
80
|
+
self.count += 1
|
|
81
|
+
try:
|
|
82
|
+
with self._opener.open(req, timeout=self.timeout) as res:
|
|
83
|
+
body, headers = res.read(), res.headers
|
|
84
|
+
if headers.get("Content-Encoding") == "gzip":
|
|
85
|
+
body = gzip.decompress(body)
|
|
86
|
+
return body, headers
|
|
87
|
+
except urllib.error.HTTPError as e:
|
|
88
|
+
with e:
|
|
89
|
+
_raise_for(e.code, e.read(), e.headers)
|
|
90
|
+
raise # unreachable, _raise_for always raises
|
|
91
|
+
# A connection cut mid-response or a damaged body is the same outage to the caller.
|
|
92
|
+
except (OSError, EOFError, http.client.HTTPException, zlib.error) as e:
|
|
93
|
+
raise NetworkError(f"cannot reach {req.host}: {e!r}") from e
|
|
94
|
+
|
|
95
|
+
def request(
|
|
96
|
+
self,
|
|
97
|
+
method: str,
|
|
98
|
+
url: str,
|
|
99
|
+
*,
|
|
100
|
+
token: str | None = None,
|
|
101
|
+
params: dict | None = None,
|
|
102
|
+
body: dict | None = None,
|
|
103
|
+
retries: int = 0,
|
|
104
|
+
) -> tuple[bytes, email.message.Message]:
|
|
105
|
+
if params:
|
|
106
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
107
|
+
headers = {"User-Agent": f"gengwiki-python/{__version__}", "Accept-Encoding": "gzip"}
|
|
108
|
+
if token:
|
|
109
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
110
|
+
data = None
|
|
111
|
+
if body is not None:
|
|
112
|
+
data = json.dumps(body).encode()
|
|
113
|
+
headers["Content-Type"] = "application/json"
|
|
114
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
115
|
+
for attempt in range(retries + 1):
|
|
116
|
+
try:
|
|
117
|
+
return self._once(req)
|
|
118
|
+
except UnavailableError as e:
|
|
119
|
+
# A long Retry-After is the daily limit, waiting it out would hang the caller.
|
|
120
|
+
wait = e.retry_after if isinstance(e, RateLimitError) else 0.5 * 2**attempt
|
|
121
|
+
if attempt == retries or wait > 5:
|
|
122
|
+
raise
|
|
123
|
+
time.sleep(wait)
|
|
124
|
+
raise AssertionError("unreachable")
|
|
125
|
+
|
|
126
|
+
def json(self, method: str, url: str, *, expect: type | None = None, **kwargs):
|
|
127
|
+
"""The decoded body. With expect, anything but that type is a bad response, so it never reaches the cache."""
|
|
128
|
+
body, headers = self.request(method, url, **kwargs)
|
|
129
|
+
try:
|
|
130
|
+
value = json.loads(body) if body else None
|
|
131
|
+
except ValueError as e:
|
|
132
|
+
raise BadResponseError(f"expected JSON from {url}, got {headers.get_content_type()}", code="bad_response") from e
|
|
133
|
+
# ponytail: checks the outer type, not each row. Check fields if something in front ever rewrites rows.
|
|
134
|
+
if expect and not isinstance(value, expect):
|
|
135
|
+
raise BadResponseError(f"expected a JSON {expect.__name__} from {url}", code="bad_response")
|
|
136
|
+
return value
|