nonebot-plugin-cnrail 0.2.0__tar.gz → 0.2.2.dev1__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,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: nonebot-plugin-cnrail
3
- Version: 0.2.0
3
+ Version: 0.2.2.dev1
4
4
  Summary: NoneBot2 plugin for query the train time table
5
5
  Home-page: https://github.com/lgc-NB2Dev/nonebot-plugin-cnrail
6
6
  Author-Email: student_2333 <lgc2333@126.com>, XieXiLin <support@xiexilin.com>
@@ -9,7 +9,7 @@ Project-URL: Homepage, https://github.com/lgc-NB2Dev/nonebot-plugin-cnrail
9
9
  Requires-Python: <4.0,>=3.9
10
10
  Requires-Dist: nonebot2>=2.2.0
11
11
  Requires-Dist: httpx>=0.24
12
- Requires-Dist: nonebot-plugin-alconna>=0.40.0rc1
12
+ Requires-Dist: nonebot-plugin-alconna>=0.40.1
13
13
  Requires-Dist: nonebot-plugin-htmlrender>=0.2.2
14
14
  Requires-Dist: jinja2>=3.1.2
15
15
  Requires-Dist: pytz>=2023.3.post1
@@ -121,7 +121,9 @@ plugins = [
121
121
 
122
122
  ## ⚙️ 配置
123
123
 
124
- 暂无
124
+ | 配置项 | 必填 | 默认值 | 说明 |
125
+ | :--------------------: | :--: | :-------------------------------: | :----------------------------: |
126
+ | `CNRAIL_ACG_IMAGE_URL` | 否 | `https://www.loliapi.com/acg/pe/` | 用于指令返回图片背景的图片 URL |
125
127
 
126
128
  ## 🎉 使用
127
129
 
@@ -162,6 +164,11 @@ Telegram:[@lgc2333](https://t.me/lgc2333)
162
164
 
163
165
  ## 📝 更新日志
164
166
 
167
+ ### 0.2.1
168
+
169
+ - 适配 [MoeFactory API](https://train.moefactory.com)
170
+ - 新增配置项 `CNRAIL_ACG_IMAGE_URL`
171
+
165
172
  ### 0.2.0
166
173
 
167
174
  - 适配 Pydantic V1 & V2
@@ -104,7 +104,9 @@ plugins = [
104
104
 
105
105
  ## ⚙️ 配置
106
106
 
107
- 暂无
107
+ | 配置项 | 必填 | 默认值 | 说明 |
108
+ | :--------------------: | :--: | :-------------------------------: | :----------------------------: |
109
+ | `CNRAIL_ACG_IMAGE_URL` | 否 | `https://www.loliapi.com/acg/pe/` | 用于指令返回图片背景的图片 URL |
108
110
 
109
111
  ## 🎉 使用
110
112
 
@@ -145,6 +147,11 @@ Telegram:[@lgc2333](https://t.me/lgc2333)
145
147
 
146
148
  ## 📝 更新日志
147
149
 
150
+ ### 0.2.1
151
+
152
+ - 适配 [MoeFactory API](https://train.moefactory.com)
153
+ - 新增配置项 `CNRAIL_ACG_IMAGE_URL`
154
+
148
155
  ### 0.2.0
149
156
 
150
157
  - 适配 Pydantic V1 & V2
@@ -6,7 +6,7 @@ require("nonebot_plugin_htmlrender")
6
6
  from . import __main__ as __main__ # noqa: E402
7
7
  from .config import ConfigModel # noqa: E402
8
8
 
9
- __version__ = "0.2.0"
9
+ __version__ = "0.2.2.dev1"
10
10
  __plugin_meta__ = PluginMetadata(
11
11
  name="CNRail",
12
12
  description="查询 12306 列车时刻表",
@@ -10,7 +10,12 @@ from nonebot import logger
10
10
  from nonebot_plugin_alconna import AlconnaMatcher, CommandResult, on_alconna
11
11
  from nonebot_plugin_alconna.uniseg import UniMessage
12
12
 
13
- from .data_source import MultipleTrainFoundError, query_train_info, render_train_info
13
+ from .data_source import (
14
+ MultipleTrainFoundError,
15
+ generate_word,
16
+ query_train_info,
17
+ render_train_info,
18
+ )
14
19
 
15
20
 
16
21
  def parse_date(date_str: str) -> date:
@@ -22,7 +27,9 @@ def parse_date(date_str: str) -> date:
22
27
  def parse(df: str) -> Optional[date]:
23
28
  with suppress(ValueError):
24
29
  parsed = (
25
- datetime.strptime(date_str, df).replace(year=today_date.year).date() # noqa: DTZ007
30
+ datetime.strptime(date_str, df)
31
+ .replace(year=today_date.year)
32
+ .date() # noqa: DTZ007
26
33
  )
27
34
  if parsed < today_date:
28
35
  parsed = parsed.replace(year=today_date.year + 1)
@@ -67,7 +74,9 @@ async def _(matcher: AlconnaMatcher, parma: Arparma):
67
74
 
68
75
  try:
69
76
  # use local timezone
70
- date_obj = parse_date(train_date) if train_date else date.today() # noqa: DTZ011
77
+ date_obj = (
78
+ parse_date(train_date) if train_date else date.today()
79
+ ) # noqa: DTZ011
71
80
  except ValueError:
72
81
  await matcher.finish("日期格式不正确")
73
82
 
@@ -75,7 +84,12 @@ async def _(matcher: AlconnaMatcher, parma: Arparma):
75
84
  train_info = await query_train_info(train_no, date_obj.isoformat())
76
85
  except MultipleTrainFoundError as e:
77
86
  much_text = "\n结果过多,仅显示前五个" if len(e.trains) > 5 else ""
78
- info_text = "\n".join(x.word for x in e.trains[:5])
87
+ info_text = "\n".join(
88
+ [
89
+ await generate_word(train_code=i, train_date=date_obj.isoformat())
90
+ for i in e.trains[:5]
91
+ ],
92
+ )
79
93
  await matcher.finish(
80
94
  f"查询到多个车次,请检查您的车次是否正确\n{info_text}{much_text}",
81
95
  )
@@ -94,7 +108,10 @@ async def _(matcher: AlconnaMatcher, parma: Arparma):
94
108
  )
95
109
 
96
110
  try:
97
- img_bytes = await render_train_info(train_info)
111
+ img_bytes = await render_train_info(
112
+ return_data=train_info,
113
+ train_date=date_obj.isoformat(),
114
+ )
98
115
  except Exception:
99
116
  logger.exception("Failed to render train info")
100
117
  await matcher.finish("渲染图片时出现错误,请检查后台输出")
@@ -0,0 +1,13 @@
1
+ from typing_extensions import Annotated
2
+
3
+ from nonebot import get_plugin_config
4
+ from pydantic import BaseModel, Field, HttpUrl
5
+
6
+
7
+ class ConfigModel(BaseModel):
8
+ CNRAIL_ACG_IMAGE_URL: Annotated[str, HttpUrl] = Field(
9
+ "https://www.loliapi.com/acg/pe/",
10
+ )
11
+
12
+
13
+ config = get_plugin_config(ConfigModel)
@@ -0,0 +1,177 @@
1
+ from pathlib import Path
2
+ from typing import List, Optional
3
+
4
+ import httpx
5
+ import jinja2
6
+ import pytz
7
+ from nonebot import logger
8
+ from nonebot.compat import type_validate_python
9
+ from nonebot_plugin_htmlrender import get_new_page
10
+ from playwright.async_api import Request, Route
11
+
12
+ from .config import config
13
+ from .models import ReturnData, TrainDetailData, TrainSearchResult, TrainSNData
14
+
15
+ MOERAIL_API_BASE = "https://train.moefactory.com/api/"
16
+
17
+ TEMPLATE_PATH = Path(__file__).parent / "templates" / "train_table.html.jinja"
18
+
19
+ ROUTE_BASE_URL = "https://cnrail.nonebot/"
20
+ ROUTE_IMAGE_URL = f"{ROUTE_BASE_URL}image"
21
+
22
+ TZ_SHANGHAI = pytz.timezone("Asia/Shanghai")
23
+
24
+
25
+ class MultipleTrainFoundError(Exception):
26
+ def __init__(self, trains: List[str]) -> None:
27
+ self.trains = trains
28
+ super().__init__(trains)
29
+
30
+
31
+ async def generate_word(train_code: str, train_date: str) -> str:
32
+ async with httpx.AsyncClient(
33
+ base_url=MOERAIL_API_BASE,
34
+ follow_redirects=True,
35
+ ) as client:
36
+ resp = await client.post(
37
+ url="/trainNumber/query",
38
+ data={
39
+ "date": train_date.replace("-", ""),
40
+ "trainNumber": train_code,
41
+ },
42
+ )
43
+ resp.raise_for_status()
44
+ result = type_validate_python(TrainSearchResult, resp.json()["data"])
45
+ return f"{result.data[0].train_number} | {result.data[0].begin_station_name} - {result.data[0].end_station_name} | 耗时 {result.data[0].duration_minutes} 分钟"
46
+
47
+
48
+ async def get_train_sn(train_code: str) -> Optional[List[TrainSNData]]:
49
+ async with httpx.AsyncClient(
50
+ base_url=MOERAIL_API_BASE,
51
+ follow_redirects=True,
52
+ ) as client:
53
+ resp = await client.post(
54
+ url="/crTrainSN/query",
55
+ data={
56
+ "keyword": train_code,
57
+ },
58
+ )
59
+ resp.raise_for_status()
60
+ return (
61
+ type_validate_python(List[TrainSNData], resp.json()["data"])
62
+ if resp.json()["data"]
63
+ else None
64
+ )
65
+
66
+
67
+ async def query_train_info(
68
+ train_code: str,
69
+ train_date: str,
70
+ ) -> Optional[ReturnData]:
71
+ async with httpx.AsyncClient(
72
+ base_url=MOERAIL_API_BASE,
73
+ follow_redirects=True,
74
+ ) as client:
75
+ resp = await client.post(
76
+ url="/trainNumber/query",
77
+ data={
78
+ "date": train_date.replace("-", ""),
79
+ "trainNumber": train_code,
80
+ },
81
+ )
82
+ resp.raise_for_status()
83
+ result = type_validate_python(TrainSearchResult, resp.json()["data"])
84
+
85
+ if result.total_count != 1 and result.data[0].train_number != train_code:
86
+ async with httpx.AsyncClient(
87
+ base_url=MOERAIL_API_BASE,
88
+ follow_redirects=True,
89
+ ) as client:
90
+ resp = await client.post(
91
+ url="/search/getTrainCandidates",
92
+ data={
93
+ "keywords": train_code,
94
+ },
95
+ )
96
+ resp.raise_for_status()
97
+ result = resp.json()["data"]
98
+ if not result:
99
+ return None
100
+ raise MultipleTrainFoundError(result)
101
+
102
+ search_data = result.data[0]
103
+
104
+ async with httpx.AsyncClient(
105
+ base_url=MOERAIL_API_BASE,
106
+ follow_redirects=True,
107
+ ) as client:
108
+ resp = await client.post(
109
+ url="/trainDetails/query",
110
+ data={
111
+ "date": train_date.replace("-", ""),
112
+ "trainIndex": search_data.train_index,
113
+ },
114
+ )
115
+ resp.raise_for_status()
116
+
117
+ datail_data = type_validate_python(TrainDetailData, resp.json()["data"])
118
+
119
+ sn_data = await get_train_sn(train_code)
120
+
121
+ return ReturnData(
122
+ search=search_data,
123
+ datail=datail_data,
124
+ sn=sn_data,
125
+ train_date=train_date,
126
+ )
127
+
128
+
129
+ async def render_train_info(return_data: ReturnData, train_date: str) -> bytes:
130
+ template = jinja2.Template(
131
+ TEMPLATE_PATH.read_text(encoding="u8"),
132
+ enable_async=True,
133
+ )
134
+ html = await template.render_async(
135
+ summary=return_data.search,
136
+ detail=return_data.datail,
137
+ sn=(
138
+ next((i.train_sn for i in return_data.sn if i.date == train_date), None)
139
+ if return_data.sn
140
+ else None
141
+ ),
142
+ train_date=return_data.train_date,
143
+ )
144
+ if (dbg := Path.cwd() / "cnrail-debug.html").exists():
145
+ dbg.write_text(html, encoding="u8")
146
+
147
+ async def bg_router(route: Route, _: Request):
148
+ async with httpx.AsyncClient(follow_redirects=True) as client:
149
+ resp = await client.get(config.CNRAIL_ACG_IMAGE_URL, follow_redirects=True)
150
+ try:
151
+ resp.raise_for_status()
152
+ except Exception:
153
+ logger.exception("Failed to fetch image")
154
+ await route.abort()
155
+ else:
156
+ await route.fulfill(
157
+ status=resp.status_code,
158
+ headers=dict(resp.headers),
159
+ body=resp.content,
160
+ )
161
+
162
+ async def root_router(route: Route, _: Request):
163
+ await route.fulfill(
164
+ status=200,
165
+ headers={"Content-Type": "text/html"},
166
+ body=html,
167
+ )
168
+
169
+ async with get_new_page() as page:
170
+ await page.route(ROUTE_BASE_URL, root_router)
171
+ await page.route(ROUTE_IMAGE_URL, bg_router)
172
+ await page.goto(ROUTE_BASE_URL)
173
+ await page.wait_for_selector("#done", state="attached")
174
+
175
+ elem = await page.query_selector(".bg-wrapper")
176
+ assert elem
177
+ return await elem.screenshot(type="jpeg")
@@ -0,0 +1,122 @@
1
+ from datetime import datetime, timedelta
2
+ from typing import List, Optional, Union
3
+ from nonebot.log import logger
4
+ import pytz
5
+ from pydantic import BaseModel, Field
6
+
7
+ TZ_SHANGHAI = pytz.timezone("Asia/Shanghai")
8
+
9
+
10
+ class TrainSearchData(BaseModel):
11
+ train_index: int = Field(alias="trainIndex")
12
+ train_number: str = Field(alias="trainNumber")
13
+ begin_station_name: str = Field(alias="beginStationName")
14
+ departure_time: str = Field(alias="departureTime")
15
+ end_station_name: str = Field(alias="endStationName")
16
+ arrival_time: str = Field(alias="arrivalTime")
17
+ day_count: int = Field(alias="dayCount")
18
+ duration_minutes: int = Field(alias="durationMinutes")
19
+ distance: int = Field(alias="distance")
20
+ train_type: str = Field(alias="trainType")
21
+ cr_type: int = Field(alias="crType")
22
+ out_of_date_flag: int = Field(alias="outOfDateFlag")
23
+
24
+ @property
25
+ def pass_time(self) -> str:
26
+ start_datetime = datetime.strptime(self.departure_time, "%H:%M")
27
+ end_datetime = datetime.strptime(self.arrival_time, "%H:%M")
28
+ if end_datetime < start_datetime:
29
+ end_datetime += timedelta(days=1)
30
+ time_difference = end_datetime - start_datetime
31
+ return f"{(str(self.day_count - 1 + time_difference.days) + ' 天') if (self.day_count - 1 + time_difference.days) > 0 else ''} {time_difference.seconds // 3600} 时 {time_difference.seconds % 3600 // 60} 分"
32
+
33
+
34
+ class TrainSearchResult(BaseModel):
35
+ page_index: int = Field(alias="pageIndex")
36
+ page_size: int = Field(alias="pageSize")
37
+ total_pages: int = Field(alias="totalPages")
38
+ total_count: int = Field(alias="totalCount")
39
+ data: List[TrainSearchData]
40
+
41
+
42
+ class TrainDetailviaSation(BaseModel):
43
+ station_name: str = Field(alias="stationName")
44
+ station_telegram_code: Optional[str] = Field(alias="stationTelegramCode")
45
+ train_number: str = Field(alias="trainNumber")
46
+ arrival_time: Optional[str] = Field(alias="arrivalTime")
47
+ departure_time: Optional[str] = Field(alias="departureTime")
48
+ stop_minutes: int = Field(alias="stopMinutes")
49
+ distance: int
50
+ checkout_name: Optional[str] = Field(alias="checkoutName")
51
+ speed: Optional[int]
52
+ day_index: int = Field(alias="dayIndex")
53
+ company_name: str = Field(alias="companyName")
54
+ province: str
55
+ district: str
56
+ out_of_date_flag: int = Field(alias="outOfDateFlag")
57
+ is_turn: bool = Field(alias="isTurn")
58
+
59
+
60
+ class TrainDetailRoutingItem(BaseModel):
61
+ train_number: str = Field(alias="trainNumber")
62
+ begin_station_name: str = Field(alias="beginStationName")
63
+ departure_time: str = Field(alias="departureTime")
64
+ end_station_name: str = Field(alias="endStationName")
65
+ arrival_time: str = Field(alias="arrivalTime")
66
+
67
+
68
+ class TrainDetailRoutingMissingItem(BaseModel):
69
+ train_number: str = Field(alias="trainNumber")
70
+ begin_station_name: Optional[str] = Field(alias="beginStationName")
71
+ departure_time: Optional[str] = Field(alias="departureTime")
72
+ end_station_name: Optional[str] = Field(alias="endStationName")
73
+ arrival_time: Optional[str] = Field(alias="arrivalTime")
74
+
75
+
76
+ class TrainDetailRouing(BaseModel):
77
+ routing_items: List[
78
+ Union[TrainDetailRoutingItem, TrainDetailRoutingMissingItem]
79
+ ] = Field(alias="routingItems")
80
+ train_model: str = Field(alias="trainModel")
81
+
82
+
83
+ class TrainDetailData(BaseModel):
84
+ train_number: str = Field(alias="trainNumber")
85
+ train_type: str = Field(alias="trainType")
86
+ company_name: str = Field(alias="companyName")
87
+ food_coach_name: Optional[str] = Field(alias="foodCoachName")
88
+ via_stations: List[TrainDetailviaSation] = Field(alias="viaStations")
89
+ cr_type: int = Field(alias="crType")
90
+ routing: TrainDetailRouing
91
+
92
+ def arrived(self, station_index: int, train_date: str) -> bool: # 有待修改
93
+ logger.debug(f"index: {station_index}, date: {train_date}")
94
+ station = self.via_stations[station_index]
95
+ arrive_time_str = (
96
+ station.arrival_time
97
+ if station.arrival_time is not None
98
+ else station.departure_time
99
+ )
100
+ arrive_datetime = (
101
+ datetime.fromisoformat(
102
+ f"{train_date}T{arrive_time_str}",
103
+ )
104
+ + timedelta(days=station.day_index)
105
+ ).replace(tzinfo=TZ_SHANGHAI)
106
+ logger.debug(
107
+ f"arrive: {arrive_time_str}, arrive_datetime: {arrive_datetime}, now: {datetime.now(TZ_SHANGHAI)}, bool: {datetime.now(TZ_SHANGHAI) >= arrive_datetime}",
108
+ )
109
+ return datetime.now(TZ_SHANGHAI) >= arrive_datetime
110
+
111
+
112
+ class TrainSNData(BaseModel):
113
+ train_sn: str = Field(alias="trainSN")
114
+ date: str
115
+ train_number: str = Field(alias="trainNumber")
116
+
117
+
118
+ class ReturnData(BaseModel):
119
+ search: TrainSearchData
120
+ datail: TrainDetailData
121
+ sn: Optional[List[TrainSNData]]
122
+ train_date: str
@@ -180,15 +180,14 @@
180
180
  border-top: 1px solid var(--split-line-color);
181
181
  margin: 10px 20px;
182
182
  padding-top: 10px;
183
- display: flex;
184
- justify-content: space-between;
183
+ line-height: 1.7;
185
184
  }
186
185
 
187
186
  .admin,
188
187
  .emu_no {
189
- font-size: 12px;
190
- color: var(--top-sub-color);
191
- text-align: left;
188
+ font-size: 12px;
189
+ color: var(--top-sub-color);
190
+ text-align: left;
192
191
  }
193
192
 
194
193
  .card-bottom {
@@ -289,46 +288,38 @@
289
288
  <div class="main">
290
289
  <div class="card-top">
291
290
  <div class="card-title">
292
- {{ " / ".join(info.station_train_codes) }}
291
+ {{ summary.train_number }}
293
292
  </div>
294
293
  <div class="station">
295
294
  <div class="station-top">
296
295
  <div class="departure-station">
297
- <div class="place">{{ info.summary.from_station }}</div>
296
+ <div class="place">{{ summary.begin_station_name }}</div>
298
297
  <div class="time">
299
298
  <div class="departure-mark">始</div>
300
- {{ info.stations[0].start_time }}
299
+ {{ summary.departure_time }}
301
300
  </div>
302
301
  </div>
303
302
  <div class="arrow-box">
304
- {% set total_time = info.total_time -%}
305
- {%- if info.arrive_next_day -%}
306
- <div class="duration">历时 {{ total_time[0] }} 时 {{ total_time[1] }} 分</div>
303
+ <div class="duration">历时 {{ summary.pass_time }}</div>
307
304
  <div class="arrow-icon">
308
305
  <div class="line"></div>
309
306
  <div class="triangle"></div>
310
307
  </div>
311
- {%- else -%}
312
- <div class="date">{{ info.date_summary }}</div>
313
- <div class="arrow-icon">
314
- <div class="line"></div>
315
- <div class="triangle"></div>
316
- </div>
317
- <div class="duration">历时 {{ total_time[0] }} 时 {{ total_time[1] }} 分</div>
318
- {%- endif %}
319
308
  </div>
320
309
  <div class="terminal-station">
321
- <div class="place">{{ info.summary.to_station }}</div>
310
+ <div class="place">{{ summary.end_station_name }}</div>
322
311
  <div class="time">
323
- {{ info.stations[-1].arrive_time }}
312
+ {{ summary.arrival_time }}
324
313
  <div class="terminal-mark">终</div>
325
314
  </div>
326
315
  </div>
327
316
  </div>
328
317
  <div class="station-bottom">
329
- <div class="admin">担当路局:{{ info.maintancer }}</div>
330
- {%- if info.emu_no -%}
331
- <div class="emu_no">车组号:{{ info.emu_no }}</div>
318
+ <div class="admin">担当路局:{{ detail.company_name }}</div>
319
+ {%- if sn -%}
320
+ <div class="emu_no">车组号:{{ sn }}</div>
321
+ {%- else -%}
322
+ <div class="emu_no">列车型号:{{ detail.routing.train_model }}</div>
332
323
  {%- endif %}
333
324
  </div>
334
325
  </div>
@@ -349,22 +340,21 @@
349
340
  </tr>
350
341
  </thead>
351
342
  <tbody>
352
- {% for station in info.stations -%}
343
+ {% for station in detail.via_stations -%}
353
344
  <tr>
354
345
  <td>
355
- <div class="station-point{% if info.arrived(loop.index0) %} arrived{% endif %}"></div>
346
+
347
+ <div class="station-point{% if detail.arrived(loop.index0, train_date) %} arrived{% endif %}"></div>
356
348
  </td>
357
349
  <td>{{ station.station_name }}</td>
358
350
  <td>
359
- {%- if station.start_time.startswith("-") %}{{ station.arrive_time }}
360
- {%- else %}{{ station.start_time }}{% endif -%}
351
+ {% if loop.first %}-{% else %}{{ station.arrival_time}}{% endif %}
361
352
  </td>
362
353
  <td>
363
- {%- if station.arrive_time.startswith("-") %}{{ station.start_time }}
364
- {%- else %}{{ station.arrive_time }}{% endif -%}
354
+ {% if loop.last %}-{% else %}{{ station.departure_time }}{% endif %}
365
355
  </td>
366
- <td>{% if loop.first %}始发站{% elif loop.last %}终点站{% else %}{{ station.stay_minutes }} 分{% endif %}</td>
367
- <td>{{ station.station_train_code }}</td>
356
+ <td>{% if loop.first %}始发站{% elif loop.last %}终点站{% else %}{{ station.stop_minutes }} 分{% endif %}</td>
357
+ <td>{{ station.train_number }}</td>
368
358
  </tr>
369
359
  {% endfor -%}
370
360
  </tbody>
@@ -595,4 +585,4 @@
595
585
  main();
596
586
  </script>
597
587
 
598
- </html>
588
+ </html>
@@ -9,14 +9,14 @@ authors = [
9
9
  dependencies = [
10
10
  "nonebot2>=2.2.0",
11
11
  "httpx>=0.24",
12
- "nonebot-plugin-alconna>=0.40.0rc1",
12
+ "nonebot-plugin-alconna>=0.40.1",
13
13
  "nonebot-plugin-htmlrender>=0.2.2",
14
14
  "jinja2>=3.1.2",
15
15
  "pytz>=2023.3.post1",
16
16
  ]
17
17
  requires-python = ">=3.9,<4.0"
18
18
  readme = "README.md"
19
- version = "0.2.0"
19
+ version = "0.2.2.dev1"
20
20
 
21
21
  [project.license]
22
22
  text = "MIT"
@@ -1,9 +0,0 @@
1
- from nonebot import get_plugin_config
2
- from pydantic import BaseModel
3
-
4
-
5
- class ConfigModel(BaseModel):
6
- pass
7
-
8
-
9
- config = get_plugin_config(ConfigModel)
@@ -1,202 +0,0 @@
1
- from datetime import datetime
2
- from pathlib import Path
3
- from typing import List, Optional
4
-
5
- import httpx
6
- import jinja2
7
- import pytz
8
- from nonebot import logger
9
- from nonebot.compat import type_validate_python
10
- from nonebot_plugin_htmlrender import get_new_page
11
- from playwright.async_api import Request, Route
12
-
13
- from .models import TrainInfo, TrainStation, TrainSummary
14
-
15
- CHINA_RAIL_SEARCH_API = "https://search.12306.cn/search/v1/train/search"
16
- CHINA_RAIL_DETAIL_API = "https://kyfw.12306.cn/otn/queryTrainInfo/query"
17
- RAIL_RE_API = "https://api.rail.re/"
18
-
19
- CNRAIL_DATA_BASE_URL = "https://cnrail-data.baka.pub/data/"
20
-
21
- ACG_IMAGE_URL = "https://www.loliapi.com/acg/pe/"
22
-
23
- TEMPLATE_PATH = Path(__file__).parent / "templates" / "train_table.html.jinja"
24
-
25
- ROUTE_BASE_URL = "https://cnrail.nonebot/"
26
- ROUTE_IMAGE_URL = f"{ROUTE_BASE_URL}image"
27
-
28
- TZ_SHANGHAI = pytz.timezone("Asia/Shanghai")
29
-
30
-
31
- class MultipleTrainFoundError(Exception):
32
- def __init__(self, trains: List[TrainSummary]) -> None:
33
- self.trains = trains
34
- super().__init__(trains)
35
-
36
-
37
- async def query_emu_from_train_code(train_code: str) -> Optional[List]:
38
- async with httpx.AsyncClient(base_url=RAIL_RE_API, follow_redirects=True) as client:
39
- resp = await client.get(f"/train/{train_code}")
40
- resp.raise_for_status()
41
-
42
- data = resp.json()
43
- if not data:
44
- return None
45
-
46
- return data
47
-
48
-
49
- async def query_emu_from_emu_no(emu_no: str) -> Optional[List]:
50
- async with httpx.AsyncClient(base_url=RAIL_RE_API, follow_redirects=True) as client:
51
- resp = await client.get(f"/emu/{emu_no}")
52
- resp.raise_for_status()
53
-
54
- data = resp.json()
55
- if not data:
56
- return None
57
-
58
- return data
59
-
60
-
61
- async def query_train_info(train_code: str, train_date: str) -> Optional[TrainInfo]:
62
- train_code = train_code.upper()
63
-
64
- async def get_search_data(train_code: str, train_date: str) -> Optional[List]:
65
- async with httpx.AsyncClient(follow_redirects=True) as client:
66
- resp = await client.get(
67
- CHINA_RAIL_SEARCH_API,
68
- params={
69
- "keyword": train_code,
70
- "date": train_date.replace("-", ""),
71
- },
72
- )
73
- resp.raise_for_status()
74
-
75
- return resp.json()["data"]
76
-
77
- raw_data = await get_search_data(train_code=train_code, train_date=train_date)
78
- if not raw_data:
79
- async with httpx.AsyncClient(
80
- base_url=CNRAIL_DATA_BASE_URL,
81
- follow_redirects=True,
82
- ) as client:
83
- resp = await client.get("/alias.json")
84
- resp.raise_for_status()
85
-
86
- try:
87
- train_code = resp.json()[train_code]
88
- except KeyError:
89
- return None
90
-
91
- raw_data = await get_search_data(train_code=train_code, train_date=train_date)
92
- if not raw_data:
93
- return None
94
-
95
- data = type_validate_python(List[TrainSummary], raw_data)
96
- if len(data) > 1:
97
- summary = next(
98
- (train for train in data if train.station_train_code == train_code),
99
- None,
100
- )
101
- if not summary:
102
- raise MultipleTrainFoundError(data)
103
- else:
104
- summary = data[0]
105
-
106
- if summary.station_train_code != train_code:
107
- return None
108
-
109
- async with httpx.AsyncClient(follow_redirects=True) as client:
110
- resp = await client.get(
111
- CHINA_RAIL_DETAIL_API,
112
- params={
113
- "leftTicketDTO.train_no": summary.train_no,
114
- "leftTicketDTO.train_date": train_date,
115
- "rand_code": "",
116
- },
117
- )
118
- resp.raise_for_status()
119
-
120
- raw_data = resp.json()["data"]["data"]
121
- if not raw_data:
122
- return None
123
-
124
- stations = type_validate_python(List[TrainStation], raw_data)
125
-
126
- async with httpx.AsyncClient(
127
- base_url=CNRAIL_DATA_BASE_URL,
128
- follow_redirects=True,
129
- ) as client:
130
- resp = await client.get("/maintance.json")
131
- resp.raise_for_status()
132
-
133
- maintancer = resp.json()[train_code]
134
-
135
- today_date = datetime.now(TZ_SHANGHAI).date()
136
- train_date_obj = (
137
- datetime.strptime(train_date, "%Y-%m-%d").replace(tzinfo=TZ_SHANGHAI).date()
138
- )
139
-
140
- emu_no = None
141
-
142
- if (train_date_obj <= today_date) and (
143
- emu_data := await query_emu_from_train_code(train_code)
144
- ):
145
- for i in emu_data:
146
- date_data = (
147
- datetime.strptime(i["date"], "%Y-%m-%d %H:%M")
148
- .replace(tzinfo=TZ_SHANGHAI)
149
- .date()
150
- )
151
- if date_data == train_date_obj:
152
- emu_no = i["emu_no"]
153
- break
154
-
155
- return TrainInfo(
156
- summary=summary,
157
- stations=stations,
158
- maintancer=maintancer,
159
- emu_no=emu_no,
160
- )
161
-
162
-
163
- async def render_train_info(info: TrainInfo) -> bytes:
164
- template = jinja2.Template(
165
- TEMPLATE_PATH.read_text(encoding="u8"),
166
- enable_async=True,
167
- )
168
- html = await template.render_async(info=info)
169
- if (dbg := Path.cwd() / "cnrail-debug.html").exists():
170
- dbg.write_text(html, encoding="u8")
171
-
172
- async def bg_router(route: Route, _: Request):
173
- async with httpx.AsyncClient(follow_redirects=True) as client:
174
- resp = await client.get(ACG_IMAGE_URL)
175
- try:
176
- resp.raise_for_status()
177
- except Exception:
178
- logger.exception("Failed to fetch image")
179
- await route.abort()
180
- else:
181
- await route.fulfill(
182
- status=resp.status_code,
183
- headers=dict(resp.headers),
184
- body=resp.content,
185
- )
186
-
187
- async def root_router(route: Route, _: Request):
188
- await route.fulfill(
189
- status=200,
190
- headers={"Content-Type": "text/html"},
191
- body=html,
192
- )
193
-
194
- async with get_new_page() as page:
195
- await page.route(ROUTE_BASE_URL, root_router)
196
- await page.route(ROUTE_IMAGE_URL, bg_router)
197
- await page.goto(ROUTE_BASE_URL)
198
- await page.wait_for_selector("#done", state="attached")
199
-
200
- elem = await page.query_selector(".bg-wrapper")
201
- assert elem
202
- return await elem.screenshot(type="jpeg")
@@ -1,102 +0,0 @@
1
- from datetime import datetime, timedelta
2
- from typing import Any, List, Optional, Tuple, cast
3
-
4
- import pytz
5
- from pydantic import BaseModel, Field, PrivateAttr
6
-
7
- TZ_SHANGHAI = pytz.timezone("Asia/Shanghai")
8
-
9
-
10
- class TrainSummary(BaseModel):
11
- date: str
12
- from_station: str
13
- station_train_code: str
14
- to_station: str
15
- total_num: str
16
- train_no: str
17
-
18
- @property
19
- def date_iso(self) -> str:
20
- return f"{self.date[:4]}-{self.date[4:6]}-{self.date[6:]}"
21
-
22
- @property
23
- def word(self) -> str:
24
- return f"{self.station_train_code} {self.from_station}-{self.to_station} | 途经 {self.total_num} 站"
25
-
26
-
27
- class TrainStation(BaseModel):
28
- arrive_day_str: str
29
- arrive_time: str
30
- station_train_code: str
31
- station_name: str
32
- arrive_day_diff: str
33
- OT: List[Any] = Field(default_factory=list)
34
- start_time: str
35
- wz_num: str
36
- station_no: str
37
- running_time: str
38
-
39
- @property
40
- def stay_minutes(self) -> int:
41
- arrive_t = datetime.strptime(self.arrive_time, "%H:%M").replace(
42
- tzinfo=TZ_SHANGHAI,
43
- )
44
- start_t = datetime.strptime(self.start_time, "%H:%M").replace(
45
- tzinfo=TZ_SHANGHAI,
46
- )
47
- if arrive_t > start_t:
48
- start_t += timedelta(days=1)
49
- return (start_t - arrive_t).seconds // 60
50
-
51
-
52
- class TrainInfo(BaseModel):
53
- summary: TrainSummary
54
- stations: List[TrainStation]
55
- maintancer: str
56
- emu_no: Optional[str]
57
-
58
- _arrive_next_day: Optional[bool] = PrivateAttr(None)
59
-
60
- @property
61
- def station_train_codes(self) -> List[str]:
62
- codes = []
63
- for x in (x for x in self.stations if x.station_train_code not in codes):
64
- codes.append(x.station_train_code) # noqa: PERF401
65
- return codes
66
-
67
- @property
68
- def total_time(self) -> Tuple[int, int]:
69
- return cast(
70
- Any,
71
- tuple(int(x) for x in self.stations[-1].running_time.split(":")),
72
- )
73
-
74
- @property
75
- def date_summary(self) -> str:
76
- week_names = ["一", "二", "三", "四", "五", "六", "日"]
77
- date = datetime.fromisoformat(self.summary.date_iso)
78
- return f"{date.month} 月 {date.day} 日 周{week_names[date.weekday()]}"
79
-
80
- @property
81
- def arrive_next_day(self) -> bool:
82
- if self._arrive_next_day is None:
83
- self._arrive_next_day = bool(
84
- next((x for x in self.stations if int(x.arrive_day_diff) > 0), None),
85
- )
86
- return self._arrive_next_day
87
-
88
- def arrived(self, station_index: int) -> bool:
89
- # 不在今日到达的车次,拿不到始发日期,摆烂了
90
- if self.arrive_next_day:
91
- return True
92
-
93
- station = self.stations[station_index]
94
- arrive_time_str = (
95
- station.arrive_time if ":" in station.arrive_time else station.start_time
96
- )
97
- arrive_datetime = datetime.fromisoformat(
98
- f"{self.summary.date_iso}T{arrive_time_str}",
99
- ).replace(tzinfo=TZ_SHANGHAI)
100
- # if (day_diff := int(station.arrive_day_diff)) > 0:
101
- # arrive_time += timedelta(days=day_diff)
102
- return datetime.now(TZ_SHANGHAI) >= arrive_datetime