nonebot-plugin-apod 1.3.0__tar.gz → 1.3.2__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.
- {nonebot_plugin_apod-1.3.0 → nonebot_plugin_apod-1.3.2}/PKG-INFO +2 -2
- {nonebot_plugin_apod-1.3.0 → nonebot_plugin_apod-1.3.2}/pyproject.toml +2 -2
- {nonebot_plugin_apod-1.3.0 → nonebot_plugin_apod-1.3.2}/src/nonebot_plugin_apod/__init__.py +4 -4
- {nonebot_plugin_apod-1.3.0 → nonebot_plugin_apod-1.3.2}/src/nonebot_plugin_apod/apod.py +2 -3
- {nonebot_plugin_apod-1.3.0 → nonebot_plugin_apod-1.3.2}/src/nonebot_plugin_apod/config.py +28 -10
- nonebot_plugin_apod-1.3.2/src/nonebot_plugin_apod/css/dark.css +49 -0
- nonebot_plugin_apod-1.3.2/src/nonebot_plugin_apod/css/light.css +48 -0
- nonebot_plugin_apod-1.3.2/src/nonebot_plugin_apod/infopuzzle.py +59 -0
- nonebot_plugin_apod-1.3.2/src/nonebot_plugin_apod/singleflight.py +38 -0
- {nonebot_plugin_apod-1.3.0 → nonebot_plugin_apod-1.3.2}/src/nonebot_plugin_apod/utils.py +3 -4
- nonebot_plugin_apod-1.3.0/src/nonebot_plugin_apod/infopuzzle.py +0 -287
- {nonebot_plugin_apod-1.3.0 → nonebot_plugin_apod-1.3.2}/README.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nonebot-plugin-apod
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.2
|
|
4
4
|
Summary: 定时发送 NASA 每日提供的天文图片
|
|
5
5
|
Keywords: nonebot2,NASA
|
|
6
6
|
Author: lyqgzbl
|
|
@@ -8,12 +8,12 @@ Author-email: lyqgzbl <admin@lyqgzbl.com>
|
|
|
8
8
|
License-Expression: MIT
|
|
9
9
|
Requires-Dist: aiofiles>=24.1.0
|
|
10
10
|
Requires-Dist: httpx>=0.28.0,<1.0.0
|
|
11
|
-
Requires-Dist: pillow>=10.0.0
|
|
12
11
|
Requires-Dist: nonebot2>=2.2.1,<3.0.0
|
|
13
12
|
Requires-Dist: nonebot-plugin-argot>=0.2.2
|
|
14
13
|
Requires-Dist: nonebot-plugin-localstore>=0.7.0,<1.0.0
|
|
15
14
|
Requires-Dist: nonebot-plugin-apscheduler>=0.5.0,<1.0.0
|
|
16
15
|
Requires-Dist: nonebot-plugin-alconna>=0.57.1,<1.0.0
|
|
16
|
+
Requires-Dist: nonebot-plugin-htmlrender>=0.3.0,<1.0.0
|
|
17
17
|
Requires-Python: >=3.10
|
|
18
18
|
Project-URL: Documentation, https://github.com/lyqgzbl/nonebot-plugin-apod#readme
|
|
19
19
|
Project-URL: Homepage, https://github.com/lyqgzbl/nonebot-plugin-apod
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "nonebot-plugin-apod"
|
|
3
|
-
version = "1.3.
|
|
3
|
+
version = "1.3.2"
|
|
4
4
|
description = "定时发送 NASA 每日提供的天文图片"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "lyqgzbl", email = "admin@lyqgzbl.com" }]
|
|
@@ -10,12 +10,12 @@ requires-python = ">=3.10"
|
|
|
10
10
|
dependencies = [
|
|
11
11
|
"aiofiles>=24.1.0",
|
|
12
12
|
"httpx >=0.28.0, <1.0.0",
|
|
13
|
-
"Pillow >=10.0.0",
|
|
14
13
|
"nonebot2 >=2.2.1, <3.0.0",
|
|
15
14
|
"nonebot-plugin-argot >=0.2.2",
|
|
16
15
|
"nonebot-plugin-localstore >=0.7.0, <1.0.0",
|
|
17
16
|
"nonebot-plugin-apscheduler >=0.5.0, <1.0.0",
|
|
18
17
|
"nonebot-plugin-alconna >=0.57.1, <1.0.0",
|
|
18
|
+
"nonebot-plugin-htmlrender >=0.3.0, <1.0.0",
|
|
19
19
|
]
|
|
20
20
|
|
|
21
21
|
[project.urls]
|
|
@@ -10,6 +10,7 @@ from nonebot.plugin import PluginMetadata, inherit_supported_adapters
|
|
|
10
10
|
require("nonebot_plugin_argot")
|
|
11
11
|
require("nonebot_plugin_alconna")
|
|
12
12
|
require("nonebot_plugin_localstore")
|
|
13
|
+
require("nonebot_plugin_htmlrender")
|
|
13
14
|
require("nonebot_plugin_apscheduler")
|
|
14
15
|
import nonebot_plugin_localstore as store
|
|
15
16
|
from nonebot_plugin_argot import Image, Text
|
|
@@ -18,7 +19,7 @@ from nonebot_plugin_argot.extension import ArgotExtension
|
|
|
18
19
|
from nonebot_plugin_alconna.uniseg import UniMessage, MsgTarget
|
|
19
20
|
from nonebot_plugin_alconna import Args, Match, Option, Alconna, CommandMeta, on_alconna
|
|
20
21
|
|
|
21
|
-
from .config import Config, plugin_config, get_cache_image
|
|
22
|
+
from .config import Config, plugin_config, get_cache_image
|
|
22
23
|
from .apod import (
|
|
23
24
|
generate_job_id,
|
|
24
25
|
remove_apod_task,
|
|
@@ -46,7 +47,7 @@ __plugin_meta__ = PluginMetadata(
|
|
|
46
47
|
supported_adapters=inherit_supported_adapters("nonebot_plugin_alconna"),
|
|
47
48
|
extra={
|
|
48
49
|
"author": "lyqgzbl <admin@lyqgzbl.com>",
|
|
49
|
-
"version": "1.3.
|
|
50
|
+
"version": "1.3.2",
|
|
50
51
|
},
|
|
51
52
|
)
|
|
52
53
|
|
|
@@ -169,10 +170,9 @@ async def apod_command_handle():
|
|
|
169
170
|
},
|
|
170
171
|
)
|
|
171
172
|
)
|
|
172
|
-
cache_image = await get_cache_image(
|
|
173
|
+
cache_image = await get_cache_image(generate_apod_image)
|
|
173
174
|
if not cache_image:
|
|
174
175
|
await apod_command.finish("发送今日的天文一图失败")
|
|
175
|
-
await set_cache_image(cache_image)
|
|
176
176
|
url = data.get("hdurl", data["url"]) if plugin_config.apod_hd_image else data["url"]
|
|
177
177
|
await UniMessage.image(raw=cache_image).send(
|
|
178
178
|
reply_to=True,
|
|
@@ -13,7 +13,7 @@ from nonebot_plugin_alconna.uniseg import MsgTarget, Target, UniMessage
|
|
|
13
13
|
|
|
14
14
|
from .infopuzzle import generate_apod_image
|
|
15
15
|
from .utils import translate_text_auto, ensure_apod_data
|
|
16
|
-
from .config import plugin_config, get_cache_image,
|
|
16
|
+
from .config import plugin_config, get_cache_image, clear_cache_image
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
driver = get_driver()
|
|
@@ -139,14 +139,13 @@ async def send_apod(target: MsgTarget):
|
|
|
139
139
|
expired_at=timedelta(minutes=2),
|
|
140
140
|
)
|
|
141
141
|
return
|
|
142
|
-
cache_image = await get_cache_image(
|
|
142
|
+
cache_image = await get_cache_image(generate_apod_image)
|
|
143
143
|
if not cache_image:
|
|
144
144
|
await UniMessage.text("发送今日的天文一图失败,请稍后再试。").send(
|
|
145
145
|
target=target,
|
|
146
146
|
bot=bot,
|
|
147
147
|
)
|
|
148
148
|
return
|
|
149
|
-
await set_cache_image(cache_image)
|
|
150
149
|
url = data.get("hdurl", data["url"]) if plugin_config.apod_hd_image else data["url"]
|
|
151
150
|
message = await UniMessage.image(raw=cache_image).send(
|
|
152
151
|
target=target,
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
from
|
|
1
|
+
from collections.abc import Awaitable, Callable
|
|
2
2
|
from pydantic import BaseModel
|
|
3
3
|
|
|
4
4
|
from nonebot import get_plugin_config
|
|
5
5
|
|
|
6
|
+
from .singleflight import SingleFlight
|
|
7
|
+
|
|
6
8
|
|
|
7
9
|
class Config(BaseModel):
|
|
8
10
|
apod_api_key: str | None = None
|
|
@@ -32,25 +34,41 @@ plugin_config = get_plugin_config(Config)
|
|
|
32
34
|
|
|
33
35
|
|
|
34
36
|
# 缓存天文一图图片
|
|
35
|
-
cache_image = None
|
|
36
|
-
|
|
37
|
+
cache_image: bytes | None = None
|
|
38
|
+
cache_flight: SingleFlight[bytes | None] = SingleFlight()
|
|
39
|
+
cache_lock = cache_flight
|
|
37
40
|
|
|
38
41
|
|
|
39
42
|
# 获取缓存图片
|
|
40
|
-
async def get_cache_image(
|
|
41
|
-
|
|
43
|
+
async def get_cache_image(
|
|
44
|
+
generator: Callable[[], Awaitable[bytes | None]] | None = None,
|
|
45
|
+
) -> bytes | None:
|
|
46
|
+
global cache_image
|
|
47
|
+
if cache_image is not None:
|
|
42
48
|
return cache_image
|
|
43
49
|
|
|
50
|
+
if generator is None:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
async def _generate_and_cache() -> bytes | None:
|
|
54
|
+
global cache_image
|
|
55
|
+
if cache_image is not None:
|
|
56
|
+
return cache_image
|
|
57
|
+
img = await generator()
|
|
58
|
+
if img:
|
|
59
|
+
cache_image = img
|
|
60
|
+
return img
|
|
61
|
+
|
|
62
|
+
return await cache_flight.do("apod_image", _generate_and_cache)
|
|
63
|
+
|
|
44
64
|
|
|
45
65
|
# 设置缓存图片
|
|
46
|
-
async def set_cache_image(image):
|
|
66
|
+
async def set_cache_image(image: bytes | None):
|
|
47
67
|
global cache_image
|
|
48
|
-
|
|
49
|
-
cache_image = image
|
|
68
|
+
cache_image = image
|
|
50
69
|
|
|
51
70
|
|
|
52
71
|
# 清除缓存图片
|
|
53
72
|
async def clear_cache_image():
|
|
54
73
|
global cache_image
|
|
55
|
-
|
|
56
|
-
cache_image = None
|
|
74
|
+
cache_image = None
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
body {
|
|
2
|
+
font-family: -apple-system, BlinkMacSystemFont, "Microsoft Yahei", "Noto Sans", sans-serif, monospace;
|
|
3
|
+
line-height: 1.6;
|
|
4
|
+
margin: 0;
|
|
5
|
+
padding: 20px;
|
|
6
|
+
background-color: #1e1e1e;
|
|
7
|
+
color: #e0e0e0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.container {
|
|
11
|
+
max-width: 800px;
|
|
12
|
+
margin: 0 auto;
|
|
13
|
+
background: #2c2c2c;
|
|
14
|
+
padding: 20px;
|
|
15
|
+
border-radius: 8px;
|
|
16
|
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
h1, h2 {
|
|
20
|
+
text-align: center;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.image-container {
|
|
24
|
+
text-align: center;
|
|
25
|
+
margin: 20px 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.image-container img {
|
|
29
|
+
max-width: 100%;
|
|
30
|
+
height: auto;
|
|
31
|
+
border-radius: 8px;
|
|
32
|
+
border: 1px solid #4a4a4a;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.explanation {
|
|
36
|
+
text-align: left;
|
|
37
|
+
font-size: 1.1em;
|
|
38
|
+
margin: 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.info {
|
|
42
|
+
text-align: left;
|
|
43
|
+
font-size: 0.9em;
|
|
44
|
+
color: #a0a0a0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.info p {
|
|
48
|
+
margin: 5px 0;
|
|
49
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
body {
|
|
2
|
+
font-family: -apple-system, BlinkMacSystemFont, "Microsoft Yahei", "Noto Sans", sans-serif, monospace;
|
|
3
|
+
line-height: 1.6;
|
|
4
|
+
margin: 0;
|
|
5
|
+
padding: 20px;
|
|
6
|
+
background-color: #f4f4f4;
|
|
7
|
+
color: #333;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.container {
|
|
11
|
+
max-width: 800px;
|
|
12
|
+
margin: 0 auto;
|
|
13
|
+
background: #fff;
|
|
14
|
+
padding: 20px;
|
|
15
|
+
border-radius: 8px;
|
|
16
|
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
h1, h2 {
|
|
20
|
+
text-align: center;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.image-container {
|
|
24
|
+
text-align: center;
|
|
25
|
+
margin: 20px 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.image-container img {
|
|
29
|
+
max-width: 100%;
|
|
30
|
+
height: auto;
|
|
31
|
+
border-radius: 8px;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.explanation {
|
|
35
|
+
text-align: left;
|
|
36
|
+
font-size: 1.1em;
|
|
37
|
+
margin: 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.info {
|
|
41
|
+
text-align: left;
|
|
42
|
+
font-size: 0.9em;
|
|
43
|
+
color: #555;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.info p {
|
|
47
|
+
margin: 5px 0;
|
|
48
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from html import escape
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import aiofiles
|
|
6
|
+
from nonebot.log import logger
|
|
7
|
+
import nonebot_plugin_localstore as store
|
|
8
|
+
from nonebot_plugin_htmlrender import md_to_pic
|
|
9
|
+
|
|
10
|
+
from .config import plugin_config
|
|
11
|
+
from .utils import ensure_apod_data, translate_text_auto
|
|
12
|
+
|
|
13
|
+
infopuzzle_mode = plugin_config.apod_infopuzzle_dark_mode
|
|
14
|
+
apod_cache_json = store.get_plugin_cache_file("apod.json")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def apod_json_to_md(apod_json: dict) -> str:
|
|
18
|
+
title = escape(apod_json["title"])
|
|
19
|
+
explanation = apod_json["explanation"]
|
|
20
|
+
url = apod_json["url"]
|
|
21
|
+
copyright = escape(apod_json.get("copyright", "无"))
|
|
22
|
+
date = escape(apod_json["date"])
|
|
23
|
+
explanation = escape(await translate_text_auto(explanation))
|
|
24
|
+
return f"""<div class="container">
|
|
25
|
+
<h1>今日天文一图</h1>
|
|
26
|
+
<h2>{title}</h2>
|
|
27
|
+
|
|
28
|
+
<div class="image-container">
|
|
29
|
+
<img src="{url}" alt="APOD">
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<p class="explanation">{explanation}</p>
|
|
33
|
+
|
|
34
|
+
<div class="info">
|
|
35
|
+
<p><strong>版权:</strong> {copyright}</p>
|
|
36
|
+
<p><strong>日期:</strong> {date}</p>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def generate_apod_image() -> bytes | None:
|
|
43
|
+
try:
|
|
44
|
+
if not await ensure_apod_data():
|
|
45
|
+
return None
|
|
46
|
+
async with aiofiles.open(apod_cache_json, encoding="utf-8") as f:
|
|
47
|
+
content = await f.read()
|
|
48
|
+
data = json.loads(content)
|
|
49
|
+
md_content = await apod_json_to_md(data)
|
|
50
|
+
css_file = (
|
|
51
|
+
Path(__file__).parent
|
|
52
|
+
/ "css"
|
|
53
|
+
/ ("dark.css" if infopuzzle_mode else "light.css")
|
|
54
|
+
)
|
|
55
|
+
img_bytes = await md_to_pic(md_content, width=600, css_path=str(css_file))
|
|
56
|
+
return img_bytes
|
|
57
|
+
except Exception as e:
|
|
58
|
+
logger.error(f"生成 NASA APOD 图片时发生错误:{e}")
|
|
59
|
+
return None
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import Awaitable, Callable
|
|
3
|
+
from typing import Any, Generic, TypeVar
|
|
4
|
+
|
|
5
|
+
T = TypeVar("T")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SingleFlight(Generic[T]):
|
|
9
|
+
def __init__(self) -> None:
|
|
10
|
+
self._calls: dict[Any, asyncio.Future] = {}
|
|
11
|
+
self._lock = asyncio.Lock()
|
|
12
|
+
|
|
13
|
+
async def do(self, key: Any, fn: Callable[[], Awaitable[T]]) -> T:
|
|
14
|
+
async with self._lock:
|
|
15
|
+
if key in self._calls:
|
|
16
|
+
fut = self._calls[key]
|
|
17
|
+
is_leader = False
|
|
18
|
+
else:
|
|
19
|
+
loop = asyncio.get_running_loop()
|
|
20
|
+
fut = loop.create_future()
|
|
21
|
+
self._calls[key] = fut
|
|
22
|
+
is_leader = True
|
|
23
|
+
|
|
24
|
+
if not is_leader:
|
|
25
|
+
return await asyncio.shield(fut)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
res = await fn()
|
|
29
|
+
if not fut.done():
|
|
30
|
+
fut.set_result(res)
|
|
31
|
+
return res
|
|
32
|
+
except BaseException as e:
|
|
33
|
+
if not fut.done():
|
|
34
|
+
fut.set_exception(e)
|
|
35
|
+
raise
|
|
36
|
+
finally:
|
|
37
|
+
async with self._lock:
|
|
38
|
+
self._calls.pop(key, None)
|
|
@@ -5,6 +5,7 @@ import hashlib
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import contextlib
|
|
7
7
|
from datetime import datetime
|
|
8
|
+
from typing import Any
|
|
8
9
|
|
|
9
10
|
import httpx
|
|
10
11
|
import aiofiles
|
|
@@ -31,9 +32,7 @@ openai_model_name = (
|
|
|
31
32
|
plugin_config.apod_openai_model_name
|
|
32
33
|
if plugin_config.apod_openai_trans or plugin_config.apod_openai_model_name
|
|
33
34
|
else (
|
|
34
|
-
plugin_config.apod_qwen_mt_model_name
|
|
35
|
-
if plugin_config.apod_qwen_trans
|
|
36
|
-
else None
|
|
35
|
+
plugin_config.apod_qwen_mt_model_name if plugin_config.apod_qwen_trans else None
|
|
37
36
|
)
|
|
38
37
|
)
|
|
39
38
|
OPENAI_API_URL = (
|
|
@@ -147,7 +146,7 @@ async def openai_translate_text(
|
|
|
147
146
|
"Authorization": f"Bearer {api_key}",
|
|
148
147
|
"Content-Type": "application/json",
|
|
149
148
|
}
|
|
150
|
-
payload = {
|
|
149
|
+
payload: dict[str, Any] = {
|
|
151
150
|
"messages": [
|
|
152
151
|
{
|
|
153
152
|
"role": "system",
|
|
@@ -1,287 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
from io import BytesIO
|
|
3
|
-
|
|
4
|
-
import aiofiles
|
|
5
|
-
from PIL import Image, ImageDraw, ImageFont
|
|
6
|
-
from nonebot.log import logger
|
|
7
|
-
from nonebot import get_driver
|
|
8
|
-
import nonebot_plugin_localstore as store
|
|
9
|
-
|
|
10
|
-
from .config import plugin_config
|
|
11
|
-
from .utils import ensure_apod_data, translate_text_auto, get_httpx_client
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
FontLike = ImageFont.FreeTypeFont | ImageFont.ImageFont
|
|
15
|
-
SCALE = 2
|
|
16
|
-
CANVAS_WIDTH = 600 * SCALE
|
|
17
|
-
PADDING = 35 * SCALE
|
|
18
|
-
CARD_PADDING = 20 * SCALE
|
|
19
|
-
CONTENT_WIDTH = CANVAS_WIDTH - 2 * PADDING - 2 * CARD_PADDING
|
|
20
|
-
SPACING = 20 * SCALE
|
|
21
|
-
CORNER_RADIUS = 8 * SCALE
|
|
22
|
-
|
|
23
|
-
FONT_BASE_URL = (
|
|
24
|
-
"https://raw.githubusercontent.com/"
|
|
25
|
-
"TCOTC/siyuan-ttf-HarmonyOS_Sans_SC-and-Twemoji/"
|
|
26
|
-
"main/HarmonyOS_Sans_Backup/HarmonyOS_SansSC/"
|
|
27
|
-
)
|
|
28
|
-
FONTS = {
|
|
29
|
-
"regular": "HarmonyOS_SansSC_Regular.ttf",
|
|
30
|
-
"bold": "HarmonyOS_SansSC_Bold.ttf",
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
data_dir = store.get_plugin_data_dir()
|
|
34
|
-
apod_cache_json = store.get_plugin_cache_file("apod.json")
|
|
35
|
-
dark_mode = plugin_config.apod_infopuzzle_dark_mode
|
|
36
|
-
|
|
37
|
-
THEMES = {
|
|
38
|
-
False: {
|
|
39
|
-
"bg": (244, 244, 244),
|
|
40
|
-
"card_bg": (255, 255, 255),
|
|
41
|
-
"title_color": (51, 51, 51),
|
|
42
|
-
"text_color": (51, 51, 51),
|
|
43
|
-
"info_color": (85, 85, 85),
|
|
44
|
-
},
|
|
45
|
-
True: {
|
|
46
|
-
"bg": (30, 30, 30),
|
|
47
|
-
"card_bg": (44, 44, 44),
|
|
48
|
-
"title_color": (224, 224, 224),
|
|
49
|
-
"text_color": (224, 224, 224),
|
|
50
|
-
"info_color": (160, 160, 160),
|
|
51
|
-
},
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
driver = get_driver()
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
@driver.on_startup
|
|
58
|
-
async def _download_fonts():
|
|
59
|
-
client = get_httpx_client()
|
|
60
|
-
for name, filename in FONTS.items():
|
|
61
|
-
path = data_dir / filename
|
|
62
|
-
if path.exists():
|
|
63
|
-
continue
|
|
64
|
-
url = FONT_BASE_URL + filename
|
|
65
|
-
logger.info(f"正在下载 HarmonyOS Sans SC {name}...")
|
|
66
|
-
try:
|
|
67
|
-
resp = await client.get(url, timeout=60)
|
|
68
|
-
resp.raise_for_status()
|
|
69
|
-
async with aiofiles.open(path, "wb") as f:
|
|
70
|
-
await f.write(resp.content)
|
|
71
|
-
logger.info(f"HarmonyOS Sans SC {name} 下载完成")
|
|
72
|
-
except Exception as e:
|
|
73
|
-
logger.warning(f"下载 HarmonyOS Sans SC {name} 失败: {e}")
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def _load_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | None:
|
|
77
|
-
key = "bold" if bold else "regular"
|
|
78
|
-
path = data_dir / FONTS[key]
|
|
79
|
-
if path.exists():
|
|
80
|
-
return ImageFont.truetype(str(path), size)
|
|
81
|
-
if bold:
|
|
82
|
-
regular = data_dir / FONTS["regular"]
|
|
83
|
-
if regular.exists():
|
|
84
|
-
return ImageFont.truetype(str(regular), size)
|
|
85
|
-
return None
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def _wrap_text(
|
|
89
|
-
draw: ImageDraw.ImageDraw,
|
|
90
|
-
text: str,
|
|
91
|
-
font: FontLike,
|
|
92
|
-
max_width: int,
|
|
93
|
-
) -> list[str]:
|
|
94
|
-
lines: list[str] = []
|
|
95
|
-
for paragraph in text.split("\n"):
|
|
96
|
-
if not paragraph:
|
|
97
|
-
lines.append("")
|
|
98
|
-
continue
|
|
99
|
-
line = ""
|
|
100
|
-
for char in paragraph:
|
|
101
|
-
test = line + char
|
|
102
|
-
if draw.textlength(test, font=font) > max_width:
|
|
103
|
-
if line:
|
|
104
|
-
lines.append(line)
|
|
105
|
-
line = char
|
|
106
|
-
else:
|
|
107
|
-
line = test
|
|
108
|
-
if line:
|
|
109
|
-
lines.append(line)
|
|
110
|
-
return lines
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def _draw_centered_lines(
|
|
114
|
-
draw: ImageDraw.ImageDraw,
|
|
115
|
-
lines: list[str],
|
|
116
|
-
font: FontLike,
|
|
117
|
-
y: int,
|
|
118
|
-
canvas_width: int,
|
|
119
|
-
fill: tuple[int, int, int],
|
|
120
|
-
line_spacing: int,
|
|
121
|
-
) -> int:
|
|
122
|
-
for line in lines:
|
|
123
|
-
tw = draw.textlength(line, font=font)
|
|
124
|
-
draw.text(((canvas_width - tw) / 2, y), line, fill=fill, font=font)
|
|
125
|
-
y += _line_height(draw, font) + line_spacing
|
|
126
|
-
return y
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def _line_height(draw: ImageDraw.ImageDraw, font: FontLike) -> int:
|
|
130
|
-
return int(draw.textbbox((0, 0), "测试Tg", font=font)[3])
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def _round_corners(img: Image.Image, radius: int) -> Image.Image:
|
|
134
|
-
img = img.convert("RGBA")
|
|
135
|
-
mask = Image.new("L", img.size, 0)
|
|
136
|
-
draw = ImageDraw.Draw(mask)
|
|
137
|
-
draw.rounded_rectangle((0, 0, *img.size), radius=radius, fill=255)
|
|
138
|
-
img.putalpha(mask)
|
|
139
|
-
return img
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
async def _fetch_image(url: str) -> Image.Image | None:
|
|
143
|
-
try:
|
|
144
|
-
client = get_httpx_client()
|
|
145
|
-
resp = await client.get(url, timeout=20)
|
|
146
|
-
resp.raise_for_status()
|
|
147
|
-
return Image.open(BytesIO(resp.content)).convert("RGB")
|
|
148
|
-
except Exception as e:
|
|
149
|
-
logger.warning(f"下载天文图片失败: {e}")
|
|
150
|
-
return None
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
async def generate_apod_image() -> bytes | None:
|
|
154
|
-
try:
|
|
155
|
-
if not await ensure_apod_data():
|
|
156
|
-
return None
|
|
157
|
-
|
|
158
|
-
font_title = _load_font(28 * SCALE, bold=True)
|
|
159
|
-
if font_title is None:
|
|
160
|
-
logger.warning("缺少字体文件, 已降级为单图模式")
|
|
161
|
-
return None
|
|
162
|
-
font_subtitle = _load_font(26 * SCALE, bold=True)
|
|
163
|
-
font_body = _load_font(20 * SCALE)
|
|
164
|
-
font_info = _load_font(14 * SCALE)
|
|
165
|
-
if font_subtitle is None or font_body is None or font_info is None:
|
|
166
|
-
logger.warning("缺少字体文件, 已降级为单图模式")
|
|
167
|
-
return None
|
|
168
|
-
|
|
169
|
-
async with aiofiles.open(apod_cache_json, encoding="utf-8") as f:
|
|
170
|
-
data = json.loads(await f.read())
|
|
171
|
-
|
|
172
|
-
theme = THEMES[dark_mode]
|
|
173
|
-
title_text = "今日天文一图"
|
|
174
|
-
subtitle_text = data["title"]
|
|
175
|
-
explanation = await translate_text_auto(data["explanation"])
|
|
176
|
-
copyright_text = f"版权:{data.get('copyright', '无')}"
|
|
177
|
-
date_text = f"日期:{data['date']}"
|
|
178
|
-
image_url = data.get("url")
|
|
179
|
-
|
|
180
|
-
tmp = Image.new("RGB", (1, 1))
|
|
181
|
-
draw = ImageDraw.Draw(tmp)
|
|
182
|
-
|
|
183
|
-
title_lines = _wrap_text(draw, title_text, font_title, CONTENT_WIDTH)
|
|
184
|
-
subtitle_lines = _wrap_text(draw, subtitle_text, font_subtitle, CONTENT_WIDTH)
|
|
185
|
-
body_lines = _wrap_text(draw, explanation, font_body, CONTENT_WIDTH)
|
|
186
|
-
|
|
187
|
-
title_lh = _line_height(draw, font_title)
|
|
188
|
-
subtitle_lh = _line_height(draw, font_subtitle)
|
|
189
|
-
body_lh = _line_height(draw, font_body)
|
|
190
|
-
info_lh = _line_height(draw, font_info)
|
|
191
|
-
|
|
192
|
-
apod_img = await _fetch_image(image_url) if image_url else None
|
|
193
|
-
img_height = 0
|
|
194
|
-
if apod_img:
|
|
195
|
-
ratio = CONTENT_WIDTH / apod_img.width
|
|
196
|
-
img_height = int(apod_img.height * ratio)
|
|
197
|
-
apod_img = apod_img.resize(
|
|
198
|
-
(CONTENT_WIDTH, img_height), Image.Resampling.LANCZOS
|
|
199
|
-
)
|
|
200
|
-
apod_img = _round_corners(apod_img, CORNER_RADIUS)
|
|
201
|
-
|
|
202
|
-
card_content_h = (
|
|
203
|
-
25 * SCALE
|
|
204
|
-
+ len(title_lines) * (title_lh + 4 * SCALE)
|
|
205
|
-
+ 25 * SCALE
|
|
206
|
-
+ len(subtitle_lines) * (subtitle_lh + 4 * SCALE)
|
|
207
|
-
+ 20 * SCALE
|
|
208
|
-
+ (img_height + SPACING if apod_img else 0)
|
|
209
|
-
+ len(body_lines) * (body_lh + 6 * SCALE)
|
|
210
|
-
+ 15 * SCALE
|
|
211
|
-
+ info_lh + 5 * SCALE
|
|
212
|
-
+ info_lh
|
|
213
|
-
)
|
|
214
|
-
card_height = 2 * CARD_PADDING + card_content_h
|
|
215
|
-
canvas_height = 2 * PADDING + card_height
|
|
216
|
-
|
|
217
|
-
canvas = Image.new("RGBA", (CANVAS_WIDTH, canvas_height), theme["bg"])
|
|
218
|
-
draw = ImageDraw.Draw(canvas)
|
|
219
|
-
|
|
220
|
-
card_x = PADDING
|
|
221
|
-
card_y = PADDING
|
|
222
|
-
draw.rounded_rectangle(
|
|
223
|
-
[card_x, card_y, CANVAS_WIDTH - PADDING, card_y + card_height],
|
|
224
|
-
radius=CORNER_RADIUS,
|
|
225
|
-
fill=theme["card_bg"],
|
|
226
|
-
)
|
|
227
|
-
|
|
228
|
-
y = card_y + CARD_PADDING
|
|
229
|
-
y += 25 * SCALE
|
|
230
|
-
y = _draw_centered_lines(
|
|
231
|
-
draw,
|
|
232
|
-
title_lines,
|
|
233
|
-
font_title,
|
|
234
|
-
y,
|
|
235
|
-
CANVAS_WIDTH,
|
|
236
|
-
theme["title_color"],
|
|
237
|
-
4 * SCALE,
|
|
238
|
-
)
|
|
239
|
-
y += 25 * SCALE
|
|
240
|
-
|
|
241
|
-
y = _draw_centered_lines(
|
|
242
|
-
draw,
|
|
243
|
-
subtitle_lines,
|
|
244
|
-
font_subtitle,
|
|
245
|
-
y,
|
|
246
|
-
CANVAS_WIDTH,
|
|
247
|
-
theme["title_color"],
|
|
248
|
-
4 * SCALE,
|
|
249
|
-
)
|
|
250
|
-
y += 20 * SCALE
|
|
251
|
-
|
|
252
|
-
content_x = card_x + CARD_PADDING
|
|
253
|
-
if apod_img:
|
|
254
|
-
canvas.paste(apod_img, (content_x, y), apod_img)
|
|
255
|
-
y += img_height + SPACING
|
|
256
|
-
|
|
257
|
-
for line in body_lines:
|
|
258
|
-
draw.text(
|
|
259
|
-
(content_x, y),
|
|
260
|
-
line,
|
|
261
|
-
fill=theme["text_color"],
|
|
262
|
-
font=font_body,
|
|
263
|
-
)
|
|
264
|
-
y += body_lh + 6 * SCALE
|
|
265
|
-
y += 15 * SCALE
|
|
266
|
-
|
|
267
|
-
draw.text(
|
|
268
|
-
(content_x, y),
|
|
269
|
-
copyright_text,
|
|
270
|
-
fill=theme["info_color"],
|
|
271
|
-
font=font_info,
|
|
272
|
-
)
|
|
273
|
-
y += info_lh + 5 * SCALE
|
|
274
|
-
draw.text(
|
|
275
|
-
(content_x, y),
|
|
276
|
-
date_text,
|
|
277
|
-
fill=theme["info_color"],
|
|
278
|
-
font=font_info,
|
|
279
|
-
)
|
|
280
|
-
|
|
281
|
-
output = canvas.convert("RGB")
|
|
282
|
-
buf = BytesIO()
|
|
283
|
-
output.save(buf, format="PNG")
|
|
284
|
-
return buf.getvalue()
|
|
285
|
-
except Exception as e:
|
|
286
|
-
logger.error(f"生成 NASA APOD 图片时发生错误:{e}")
|
|
287
|
-
return None
|
|
File without changes
|