nonebot-plugin-apod 1.3.2__tar.gz → 1.3.3__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.
@@ -1,19 +1,20 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nonebot-plugin-apod
3
- Version: 1.3.2
3
+ Version: 1.3.3
4
4
  Summary: 定时发送 NASA 每日提供的天文图片
5
5
  Keywords: nonebot2,NASA
6
6
  Author: lyqgzbl
7
7
  Author-email: lyqgzbl <admin@lyqgzbl.com>
8
8
  License-Expression: MIT
9
9
  Requires-Dist: aiofiles>=24.1.0
10
+ Requires-Dist: tzdata>=2024.1
10
11
  Requires-Dist: httpx>=0.28.0,<1.0.0
11
12
  Requires-Dist: nonebot2>=2.2.1,<3.0.0
12
13
  Requires-Dist: nonebot-plugin-argot>=0.2.2
13
14
  Requires-Dist: nonebot-plugin-localstore>=0.7.0,<1.0.0
14
15
  Requires-Dist: nonebot-plugin-apscheduler>=0.5.0,<1.0.0
15
16
  Requires-Dist: nonebot-plugin-alconna>=0.57.1,<1.0.0
16
- Requires-Dist: nonebot-plugin-htmlrender>=0.3.0,<1.0.0
17
+ Requires-Dist: nonebot-plugin-htmlrender>=0.6.7,<0.7.0
17
18
  Requires-Python: >=3.10
18
19
  Project-URL: Documentation, https://github.com/lyqgzbl/nonebot-plugin-apod#readme
19
20
  Project-URL: Homepage, https://github.com/lyqgzbl/nonebot-plugin-apod
@@ -81,11 +82,11 @@ pip install nonebot-plugin-apod
81
82
 
82
83
  配置方式:直接在 NoneBot 全局配置文件中添加以下配置项即可
83
84
 
84
- ### apod_api_key [必填]
85
+ ### apod_api_key [直连时必填]
85
86
 
86
87
  - 类型:`str`
87
88
  - 默认值:`None`
88
- - 说明:用于获取每日天文一图的 [NASA API Key](https://api.nasa.gov/)
89
+ - 说明:用于获取每日天文一图的 [NASA API Key](https://api.nasa.gov/)。配置镜像地址和镜像密钥后可省略;镜像失败后的直连回退使用 `DEMO_KEY`。
89
90
 
90
91
  ### apod_default_send_time [选填]
91
92
 
@@ -159,4 +160,33 @@ pip install nonebot-plugin-apod
159
160
  - 默认值:`https://api.openai.com/v1`
160
161
  - 说明:OpenAI 兼容接口的 Base API URL(如 DeepSeek 可填 `https://api.deepseek.com`,Qwen 可填 `https://dashscope.aliyuncs.com/compatible-mode/v1`)
161
162
 
162
- > **注**:为保持向后兼容,旧版本的 `apod_qwen_trans`、`apod_qwen_mt_model_name`、`apod_qwen_mt_api_key` 和 `apod_qwen_mt_api_url` 配置项依然生效。
163
+ > **注**:为保持向后兼容,旧版本的 `apod_qwen_trans`、`apod_qwen_mt_model_name`、`apod_qwen_mt_api_key` 和 `apod_qwen_mt_api_url` 配置项依然生效。
164
+
165
+ ## NASA Science 主站迁移
166
+
167
+ 直连使用 `https://science.nasa.gov/wp-json/wp/v2/apod-basic`。插件兼容对象和数组响应,严格匹配日期;集合接口未返回目标日期时,使用同端点下的 `YYMMDD` 日期路径。今日数据和缓存日期以 `America/New_York` 为准。
168
+
169
+ NASA 图片响应中的 `url` 可能指向文章页,因此插件将 `hdurl` 映射为内部图片 `url`,并将标题、说明和版权中的 HTML 转为纯文本。镜像响应的 `url` 保持不变,继续使用 apod-server 的图片缓存。视频仍提示为天文视频。
170
+
171
+ 新端点的 `count=1` 实测返回最近多条记录,因此随机功能改为在 1995-06-16 至 NASA 当天之间随机选日期,再请求该日期;最多尝试三个日期。今日、指定日期和随机功能均支持镜像优先、NASA 直连回退。
172
+
173
+ 镜像配置示例:
174
+
175
+ ```dotenv
176
+ APOD_MIRROR_URL=https://your-apod-server.example/v1/apod
177
+ APOD_MIRROR_API_KEY=your_server_bearer_key
178
+ ```
179
+
180
+ `APOD_MIRROR_URL` 是完整元数据接口地址,`APOD_MIRROR_API_KEY` 对应服务端 `API_AUTH_KEY`,不等同于 NASA API Key。启用高清原图时仍可能直接访问 NASA 图片 CDN。
181
+
182
+
183
+ ### htmlrender 版本兼容
184
+
185
+ 当前拼图使用 `md_to_pic` 接口,依赖限制为 `nonebot-plugin-htmlrender>=0.6.7,<0.7.0`。htmlrender 新版更改了公开接口,0.8 已移除该旧接口,不能直接升级到新版。若安装环境出现 `cannot import name 'md_to_pic'`,请在机器人项目中重新解析依赖并同步环境;使用 uv 的项目可执行:
186
+
187
+ ```bash
188
+ uv add "nonebot-plugin-htmlrender>=0.6.7,<0.7.0"
189
+ uv sync
190
+ ```
191
+
192
+ 仅更新 APOD 源码不会自动调整已安装依赖。后续若迁移新版 htmlrender,需要同时适配渲染 API、返回值、Provider 和配置。
@@ -59,11 +59,11 @@ pip install nonebot-plugin-apod
59
59
 
60
60
  配置方式:直接在 NoneBot 全局配置文件中添加以下配置项即可
61
61
 
62
- ### apod_api_key [必填]
62
+ ### apod_api_key [直连时必填]
63
63
 
64
64
  - 类型:`str`
65
65
  - 默认值:`None`
66
- - 说明:用于获取每日天文一图的 [NASA API Key](https://api.nasa.gov/)
66
+ - 说明:用于获取每日天文一图的 [NASA API Key](https://api.nasa.gov/)。配置镜像地址和镜像密钥后可省略;镜像失败后的直连回退使用 `DEMO_KEY`。
67
67
 
68
68
  ### apod_default_send_time [选填]
69
69
 
@@ -137,4 +137,33 @@ pip install nonebot-plugin-apod
137
137
  - 默认值:`https://api.openai.com/v1`
138
138
  - 说明:OpenAI 兼容接口的 Base API URL(如 DeepSeek 可填 `https://api.deepseek.com`,Qwen 可填 `https://dashscope.aliyuncs.com/compatible-mode/v1`)
139
139
 
140
- > **注**:为保持向后兼容,旧版本的 `apod_qwen_trans`、`apod_qwen_mt_model_name`、`apod_qwen_mt_api_key` 和 `apod_qwen_mt_api_url` 配置项依然生效。
140
+ > **注**:为保持向后兼容,旧版本的 `apod_qwen_trans`、`apod_qwen_mt_model_name`、`apod_qwen_mt_api_key` 和 `apod_qwen_mt_api_url` 配置项依然生效。
141
+
142
+ ## NASA Science 主站迁移
143
+
144
+ 直连使用 `https://science.nasa.gov/wp-json/wp/v2/apod-basic`。插件兼容对象和数组响应,严格匹配日期;集合接口未返回目标日期时,使用同端点下的 `YYMMDD` 日期路径。今日数据和缓存日期以 `America/New_York` 为准。
145
+
146
+ NASA 图片响应中的 `url` 可能指向文章页,因此插件将 `hdurl` 映射为内部图片 `url`,并将标题、说明和版权中的 HTML 转为纯文本。镜像响应的 `url` 保持不变,继续使用 apod-server 的图片缓存。视频仍提示为天文视频。
147
+
148
+ 新端点的 `count=1` 实测返回最近多条记录,因此随机功能改为在 1995-06-16 至 NASA 当天之间随机选日期,再请求该日期;最多尝试三个日期。今日、指定日期和随机功能均支持镜像优先、NASA 直连回退。
149
+
150
+ 镜像配置示例:
151
+
152
+ ```dotenv
153
+ APOD_MIRROR_URL=https://your-apod-server.example/v1/apod
154
+ APOD_MIRROR_API_KEY=your_server_bearer_key
155
+ ```
156
+
157
+ `APOD_MIRROR_URL` 是完整元数据接口地址,`APOD_MIRROR_API_KEY` 对应服务端 `API_AUTH_KEY`,不等同于 NASA API Key。启用高清原图时仍可能直接访问 NASA 图片 CDN。
158
+
159
+
160
+ ### htmlrender 版本兼容
161
+
162
+ 当前拼图使用 `md_to_pic` 接口,依赖限制为 `nonebot-plugin-htmlrender>=0.6.7,<0.7.0`。htmlrender 新版更改了公开接口,0.8 已移除该旧接口,不能直接升级到新版。若安装环境出现 `cannot import name 'md_to_pic'`,请在机器人项目中重新解析依赖并同步环境;使用 uv 的项目可执行:
163
+
164
+ ```bash
165
+ uv add "nonebot-plugin-htmlrender>=0.6.7,<0.7.0"
166
+ uv sync
167
+ ```
168
+
169
+ 仅更新 APOD 源码不会自动调整已安装依赖。后续若迁移新版 htmlrender,需要同时适配渲染 API、返回值、Provider 和配置。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nonebot-plugin-apod"
3
- version = "1.3.2"
3
+ version = "1.3.3"
4
4
  description = "定时发送 NASA 每日提供的天文图片"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "lyqgzbl", email = "admin@lyqgzbl.com" }]
@@ -9,13 +9,14 @@ keywords = ["nonebot2", "NASA"]
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
11
11
  "aiofiles>=24.1.0",
12
+ "tzdata>=2024.1",
12
13
  "httpx >=0.28.0, <1.0.0",
13
14
  "nonebot2 >=2.2.1, <3.0.0",
14
15
  "nonebot-plugin-argot >=0.2.2",
15
16
  "nonebot-plugin-localstore >=0.7.0, <1.0.0",
16
17
  "nonebot-plugin-apscheduler >=0.5.0, <1.0.0",
17
18
  "nonebot-plugin-alconna >=0.57.1, <1.0.0",
18
- "nonebot-plugin-htmlrender >=0.3.0, <1.0.0",
19
+ "nonebot-plugin-htmlrender >=0.6.7, <0.7.0",
19
20
  ]
20
21
 
21
22
  [project.urls]
@@ -47,7 +47,7 @@ __plugin_meta__ = PluginMetadata(
47
47
  supported_adapters=inherit_supported_adapters("nonebot_plugin_alconna"),
48
48
  extra={
49
49
  "author": "lyqgzbl <admin@lyqgzbl.com>",
50
- "version": "1.3.2",
50
+ "version": "1.3.3",
51
51
  },
52
52
  )
53
53
 
@@ -60,15 +60,15 @@ apod_cache_json = store.get_plugin_cache_file("apod.json")
60
60
  task_config_file = store.get_plugin_data_file("apod_task_config.json")
61
61
 
62
62
 
63
- if not plugin_config.apod_api_key:
63
+ if not (plugin_config.apod_api_key or (mirror_url and mirror_api_key)):
64
64
  logger.opt(colors=True).warning(
65
- "<yellow>缺失必要配置项 'apod_api_key',已禁用该插件</yellow>"
65
+ "<yellow>未配置 NASA API Key 或完整镜像配置,已禁用该插件</yellow>"
66
66
  )
67
67
 
68
68
 
69
69
  def is_enable() -> Rule:
70
70
  def _rule() -> bool:
71
- return bool(plugin_config.apod_api_key)
71
+ return bool(plugin_config.apod_api_key or (mirror_url and mirror_api_key))
72
72
 
73
73
  return Rule(_rule)
74
74
 
@@ -0,0 +1,113 @@
1
+ """Normalize NASA Science data to the plugin's existing APOD contract."""
2
+
3
+ import re
4
+ from datetime import date, datetime
5
+ from html.parser import HTMLParser
6
+ from urllib.parse import urlsplit
7
+ from zoneinfo import ZoneInfo
8
+
9
+ NASA_API_URL = "https://science.nasa.gov/wp-json/wp/v2/apod-basic"
10
+
11
+
12
+ def nasa_today() -> date:
13
+ return datetime.now(ZoneInfo("America/New_York")).date()
14
+
15
+
16
+ class _Text(HTMLParser):
17
+ def __init__(self):
18
+ super().__init__(convert_charrefs=True)
19
+ self.parts: list[str] = []
20
+ self.hidden = 0
21
+
22
+ def handle_starttag(self, tag, attrs):
23
+ if tag in {"script", "style", "noscript"}:
24
+ self.hidden += 1
25
+ if tag in {"p", "br", "div"}:
26
+ self.parts.append(" ")
27
+
28
+ def handle_endtag(self, tag):
29
+ if tag in {"script", "style", "noscript"} and self.hidden:
30
+ self.hidden -= 1
31
+ if tag in {"p", "div"}:
32
+ self.parts.append(" ")
33
+
34
+ def handle_data(self, data):
35
+ if not self.hidden:
36
+ self.parts.append(data)
37
+
38
+
39
+ def plain_text(value: object) -> str:
40
+ if not isinstance(value, str):
41
+ return ""
42
+ parser = _Text()
43
+ parser.feed(value)
44
+ return " ".join("".join(parser.parts).split())
45
+
46
+
47
+ def valid_media(
48
+ value: object, permalink: object = None, *, allow_article: bool = False
49
+ ) -> bool:
50
+ if (
51
+ not isinstance(value, str)
52
+ or not value.strip()
53
+ or (not allow_article and value == permalink)
54
+ ):
55
+ return False
56
+ try:
57
+ url = urlsplit(value)
58
+ return (
59
+ url.scheme in {"http", "https"}
60
+ and bool(url.hostname)
61
+ and url.username is None
62
+ and not (
63
+ not allow_article
64
+ and url.hostname == "science.nasa.gov"
65
+ and url.path.startswith("/image-article/")
66
+ )
67
+ )
68
+ except ValueError:
69
+ return False
70
+
71
+
72
+ def normalize_apod(payload: object, expected_date: str, *, nasa: bool) -> dict | None:
73
+ records = payload if isinstance(payload, list) else [payload]
74
+ for record in records:
75
+ if not isinstance(record, dict) or record.get("date") != expected_date:
76
+ continue
77
+ result = _normalize_record(record, nasa=nasa)
78
+ if result is not None:
79
+ return result
80
+ return None
81
+
82
+
83
+ def _normalize_record(record: dict, *, nasa: bool) -> dict | None:
84
+ kind = record.get("media_type")
85
+ media = record.get("hdurl") if nasa and kind == "image" else record.get("url")
86
+ if kind not in {"image", "video"} or not valid_media(
87
+ media, record.get("permalink"), allow_article=kind == "video"
88
+ ):
89
+ return None
90
+ title = plain_text(record.get("title")) if nasa else record.get("title")
91
+ explanation = record.get("explanation")
92
+ if nasa:
93
+ explanation = plain_text(explanation)
94
+ explanation = re.sub(r"^Explanation:\s*", "", explanation, flags=re.I)
95
+ explanation = re.split(
96
+ r"APOD['’]s (?:email for image submissions|main NASA site)"
97
+ r"|Tomorrow['’]s picture:",
98
+ explanation,
99
+ maxsplit=1,
100
+ flags=re.I,
101
+ )[0].strip()
102
+ if not isinstance(title, str) or not title.strip():
103
+ return None
104
+ if not isinstance(explanation, str) or not explanation.strip():
105
+ return None
106
+ result = dict(record, title=title, explanation=explanation, url=media)
107
+ if nasa:
108
+ result["copyright"] = plain_text(record.get("copyright"))
109
+ result["service_version"] = "v1"
110
+ result.pop("basic_html", None)
111
+ if kind == "image" and not valid_media(result.get("hdurl")):
112
+ result["hdurl"] = media
113
+ return result
@@ -4,7 +4,7 @@ import random
4
4
  import hashlib
5
5
  import asyncio
6
6
  import contextlib
7
- from datetime import datetime
7
+ from datetime import date as calendar_date, datetime, timedelta
8
8
  from typing import Any
9
9
 
10
10
  import httpx
@@ -14,13 +14,13 @@ from nonebot import get_driver
14
14
  import nonebot_plugin_localstore as store
15
15
 
16
16
  from .config import plugin_config
17
+ from .nasa import NASA_API_URL, nasa_today, normalize_apod
17
18
 
18
19
  nasa_api_key = plugin_config.apod_api_key
19
20
  baidu_trans = plugin_config.apod_baidu_trans
20
21
  deepl_trans = plugin_config.apod_deepl_trans
21
22
  openai_trans = plugin_config.apod_openai_trans or plugin_config.apod_qwen_trans
22
23
  apod_infopuzzle = plugin_config.apod_infopuzzle
23
- NASA_API_URL = "https://api.nasa.gov/planetary/apod"
24
24
  baidu_trans_appid = plugin_config.apod_baidu_trans_appid
25
25
  DEEPL_API_URL = "https://api-free.deepl.com/v2/translate"
26
26
  deepl_trans_api_key = plugin_config.apod_deepl_trans_api_key
@@ -103,9 +103,9 @@ async def ensure_apod_data() -> bool:
103
103
  try:
104
104
  content = apod_cache_json.read_text(encoding="utf-8")
105
105
  data = json.loads(content)
106
- cached_date = data.get("date", "")
107
- today = datetime.now().strftime("%Y-%m-%d")
108
- if cached_date == today:
106
+ cached_date = data.get("date", "") if isinstance(data, dict) else ""
107
+ today = nasa_today().isoformat()
108
+ if isinstance(data, dict) and cached_date == today:
109
109
  return True
110
110
  logger.debug(f"天文一图数据缓存过期({cached_date}),将重新获取")
111
111
  except (json.JSONDecodeError, KeyError, OSError) as e:
@@ -267,55 +267,54 @@ async def translate_text_auto(text: str, timeout: int = 8) -> str:
267
267
 
268
268
 
269
269
  async def fetch_apod_data() -> bool:
270
- try:
271
- client = get_httpx_client()
272
- response = await client.get(NASA_API_URL, params={"api_key": nasa_api_key})
273
- response.raise_for_status()
274
- data = response.json()
275
- async with aiofiles.open(apod_cache_json, "w", encoding="utf-8") as f:
276
- await f.write(json.dumps(data, indent=4))
277
- return True
278
- except (httpx.RequestError, httpx.HTTPStatusError) as e:
279
- logger.error(f"获取 NASA 每日天文一图数据时发生错误: {e}")
270
+ data = await fetch_apod_data_by_date(nasa_today().isoformat())
271
+ if data is None:
280
272
  return False
273
+ async with aiofiles.open(apod_cache_json, "w", encoding="utf-8") as f:
274
+ await f.write(json.dumps(data, indent=4))
275
+ return True
281
276
 
282
277
 
283
278
  async def fetch_apod_data_by_date(date: str) -> dict | None:
284
279
  try:
280
+ parsed_date = calendar_date.fromisoformat(date)
285
281
  client = get_httpx_client()
282
+ params = {"api_key": nasa_api_key or "DEMO_KEY", "date": date}
283
+ response = await client.get(NASA_API_URL, params=params)
284
+ response.raise_for_status()
285
+ data = normalize_apod(response.json(), date, nasa=True)
286
+ if data is not None:
287
+ return data
288
+ # The collection endpoint currently ignores some date queries. Its
289
+ # date-specific route returns the requested historical entry directly.
286
290
  response = await client.get(
287
- NASA_API_URL,
288
- params={"api_key": nasa_api_key, "date": date},
291
+ f"{NASA_API_URL}/{parsed_date.strftime('%y%m%d')}",
292
+ params={"api_key": nasa_api_key or "DEMO_KEY"},
289
293
  )
290
294
  response.raise_for_status()
291
- data = response.json()
292
- if isinstance(data, dict):
293
- return data
294
- if isinstance(data, list) and len(data) > 0:
295
- return data[0]
296
- return None
297
- except (httpx.RequestError, httpx.HTTPStatusError) as e:
295
+ return normalize_apod(response.json(), date, nasa=True)
296
+ except (httpx.RequestError, httpx.HTTPStatusError, ValueError) as e:
298
297
  logger.error(f"获取 NASA 指定日期天文一图数据时发生错误: {e}")
299
298
  return None
300
299
 
301
300
 
302
301
  async def fetch_randomly_apod_data() -> dict | None:
303
- try:
304
- client = get_httpx_client()
305
- response = await client.get(
306
- NASA_API_URL,
307
- params={"api_key": nasa_api_key, "count": 1},
308
- )
309
- response.raise_for_status()
310
- data = response.json()
311
- if isinstance(data, list) and len(data) > 0:
312
- return data[0]
313
- if isinstance(data, dict):
302
+ # Do not rely on count: NASA currently returns its latest collection even
303
+ # for count=1. Sample a date across the full APOD archive instead.
304
+ first = calendar_date(1995, 6, 16)
305
+ span = (nasa_today() - first).days
306
+ for _ in range(3):
307
+ selected = (first + timedelta(days=random.randint(0, span))).isoformat()
308
+ if mirror_url and mirror_api_key:
309
+ data = await fetch_apod_data_by_date_from_mirror(
310
+ mirror_url, mirror_api_key, selected
311
+ )
312
+ if data is not None:
313
+ return data
314
+ data = await fetch_apod_data_by_date(selected)
315
+ if data is not None:
314
316
  return data
315
- return None
316
- except (httpx.RequestError, httpx.HTTPStatusError) as e:
317
- logger.error(f"获取 NASA 随机天文一图数据时发生错误: {e}")
318
- return None
317
+ return None
319
318
 
320
319
 
321
320
  async def fetch_apod_data_from_mirror(url: str, api_key: str) -> bool:
@@ -324,12 +323,17 @@ async def fetch_apod_data_from_mirror(url: str, api_key: str) -> bool:
324
323
  headers = {"Authorization": f"Bearer {api_key}"}
325
324
  response = await client.get(url, headers=headers)
326
325
  response.raise_for_status()
327
- data = response.json()
326
+ payload = response.json()
327
+ # The mirror may intentionally serve its latest cached day.
328
+ date = payload.get("date") if isinstance(payload, dict) else None
329
+ data = normalize_apod(payload, date, nasa=False) if date else None
330
+ if data is None:
331
+ return False
328
332
  async with aiofiles.open(apod_cache_json, "w", encoding="utf-8") as f:
329
333
  await f.write(json.dumps(data, indent=4))
330
334
  logger.debug("成功通过镜像获取天文一图数据")
331
335
  return True
332
- except (httpx.RequestError, httpx.HTTPStatusError) as e:
336
+ except (httpx.RequestError, httpx.HTTPStatusError, ValueError) as e:
333
337
  logger.error(f"通过镜像获取天文一图数据时发生错误: {e}")
334
338
  return False
335
339
 
@@ -346,9 +350,8 @@ async def fetch_apod_data_by_date_from_mirror(
346
350
  params={"date": date},
347
351
  )
348
352
  response.raise_for_status()
349
- data = response.json()
350
- return data
351
- except (httpx.RequestError, httpx.HTTPStatusError) as e:
353
+ return normalize_apod(response.json(), date, nasa=False)
354
+ except (httpx.RequestError, httpx.HTTPStatusError, ValueError) as e:
352
355
  logger.error(f"通过镜像获取指定日期天文一图数据时发生错误: {e}")
353
356
  return None
354
357