nonebot-plugin-hibank 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from nonebot import require
4
+ from nonebot.plugin import PluginMetadata
5
+
6
+ require("nonebot_plugin_localstore")
7
+
8
+ from .config import HibankConfig # noqa: E402
9
+
10
+ __plugin_meta__ = PluginMetadata(
11
+ name="HiBank 城市银行查询",
12
+ description="查询 HiBank 城市银行分布与银行网点信息的 NoneBot2 插件。",
13
+ usage=(
14
+ "/bank 城市 <城市名>\n"
15
+ "/bank 网点 <城市名> <银行名> [页码]\n"
16
+ "/银行 <城市名>\n"
17
+ "/网点 <城市名> <银行名> [页码]\n"
18
+ "/bank 搜城市 <关键词>\n"
19
+ "/bank 搜银行 <关键词>\n"
20
+ "/bank 缓存\n"
21
+ "/bank 清缓存\n"
22
+ "/标记 <银行名...> / /mark <银行名...>\n"
23
+ "/取消标记 <银行名...> / /unmark <银行名...>\n"
24
+ "/批量标记 <城市名> <分类>\n"
25
+ "/批量取消标记 <城市名> <分类>\n"
26
+ "/复制标记 <@用户/QQ号>\n"
27
+ "/标记列表\n"
28
+ "/关注 <银行名...> / /follow <银行名...>\n"
29
+ "/取消关注 <银行名...> / /unfollow <银行名...>\n"
30
+ "/批量关注 <城市名> <分类>\n"
31
+ "/批量取消关注 <城市名> <分类>\n"
32
+ "/复制关注 <@用户/QQ号>\n"
33
+ "/关注列表\n"
34
+ "/搜城市 <关键词>\n"
35
+ "/搜银行 <关键词>"
36
+ ),
37
+ type="application",
38
+ homepage="https://github.com/WhyPilotXia/nonebot-plugin-hibank",
39
+ config=HibankConfig,
40
+ supported_adapters={"~onebot.v11"},
41
+ )
42
+
43
+ from . import commands as commands # noqa: E402
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from nonebot import require
9
+
10
+ require("nonebot_plugin_localstore")
11
+
12
+ import nonebot_plugin_localstore as localstore # noqa: E402
13
+
14
+
15
+ DATA_DIR: Path = localstore.get_plugin_data_dir()
16
+ INDEXES_FILE = DATA_DIR / "indexes.json"
17
+ CITY_DIR = DATA_DIR / "cities"
18
+ BRANCH_DIR = DATA_DIR / "branches"
19
+ USER_MARKS_FILE = DATA_DIR / "user_marks.json"
20
+
21
+
22
+ def ensure_dirs() -> None:
23
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
24
+ CITY_DIR.mkdir(parents=True, exist_ok=True)
25
+ BRANCH_DIR.mkdir(parents=True, exist_ok=True)
26
+
27
+
28
+ def safe_name(value: str) -> str:
29
+ result = []
30
+ for char in value:
31
+ if char.isalnum() or char in {"-", "_"}:
32
+ result.append(char)
33
+ else:
34
+ result.append("_")
35
+ return "".join(result).strip("_") or "cache"
36
+
37
+
38
+ def read_json(path: Path) -> Any | None:
39
+ try:
40
+ if not path.exists():
41
+ return None
42
+ return json.loads(path.read_text(encoding="utf-8"))
43
+ except (OSError, json.JSONDecodeError):
44
+ return None
45
+
46
+
47
+ def write_json(path: Path, data: Any) -> None:
48
+ ensure_dirs()
49
+ path.write_text(
50
+ json.dumps(data, ensure_ascii=False, indent=2),
51
+ encoding="utf-8",
52
+ )
53
+
54
+
55
+ def city_cache_path(city_key: str) -> Path:
56
+ return CITY_DIR / f"{safe_name(city_key)}.json"
57
+
58
+
59
+ def branch_cache_path(city_key: str, bank_name: str) -> Path:
60
+ return BRANCH_DIR / f"{safe_name(city_key)}__{safe_name(bank_name)}.json"
61
+
62
+
63
+ def clear_cache() -> None:
64
+ if INDEXES_FILE.exists():
65
+ INDEXES_FILE.unlink()
66
+ if CITY_DIR.exists():
67
+ shutil.rmtree(CITY_DIR)
68
+ if BRANCH_DIR.exists():
69
+ shutil.rmtree(BRANCH_DIR)
70
+ ensure_dirs()
71
+
72
+
73
+ def cache_counts() -> tuple[int, int]:
74
+ ensure_dirs()
75
+ city_count = len(list(CITY_DIR.glob("*.json")))
76
+ branch_count = len(list(BRANCH_DIR.glob("*.json")))
77
+ return city_count, branch_count
@@ -0,0 +1,388 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import html
5
+ import json
6
+ import re
7
+ import warnings
8
+ from typing import Any
9
+ from urllib.parse import quote
10
+
11
+ import requests
12
+ from bs4 import BeautifulSoup
13
+ from urllib3.exceptions import InsecureRequestWarning
14
+ from nonebot import get_plugin_config
15
+
16
+ from . import cache
17
+ from .config import HibankConfig
18
+ from .models import BankRef, BranchDetail, CacheStats, CityDetail, CityRef
19
+
20
+
21
+ STATE_RE = re.compile(
22
+ r"window\.__HIBANK_PINIA_STATE__\s*=\s*(\{.*?\})</script>",
23
+ re.S,
24
+ )
25
+ BRANCH_HREF_RE = re.compile(r"^/branches/([^/]+)/([^/]+)/(.+)$")
26
+ SUFFIXES = (
27
+ "省",
28
+ "市",
29
+ "自治区",
30
+ "特别行政区",
31
+ "壮族自治区",
32
+ "回族自治区",
33
+ "维吾尔自治区",
34
+ "自治州",
35
+ "地区",
36
+ "盟",
37
+ )
38
+
39
+
40
+ class HibankError(RuntimeError):
41
+ pass
42
+
43
+
44
+ class HibankClient:
45
+ def __init__(self) -> None:
46
+ self.config = get_plugin_config(HibankConfig)
47
+ self._indexes: dict[str, Any] | None = None
48
+
49
+ @property
50
+ def base_url(self) -> str:
51
+ return self.config.hibank_base_url.rstrip("/")
52
+
53
+ async def ensure_indexes(self) -> dict[str, Any]:
54
+ if self._indexes is not None:
55
+ return self._indexes
56
+ cached = cache.read_json(cache.INDEXES_FILE)
57
+ if self._indexes_valid(cached):
58
+ self._indexes = cached
59
+ return cached
60
+ html_text = await self._fetch_text("/cities")
61
+ state = self._parse_state(html_text)
62
+ indexes = state.get("data", {}).get("indexes", {})
63
+ if not self._indexes_valid(indexes):
64
+ raise HibankError("城市索引解析失败。")
65
+ cache.write_json(cache.INDEXES_FILE, indexes)
66
+ self._indexes = indexes
67
+ return indexes
68
+
69
+ async def search_cities(self, keyword: str, limit: int = 20) -> list[CityRef]:
70
+ keyword_norm = normalize(keyword)
71
+ if not keyword_norm:
72
+ return []
73
+ results: list[tuple[int, CityRef]] = []
74
+ for city in await self.iter_cities():
75
+ city_norm = normalize(city.city)
76
+ province_norm = normalize(city.province)
77
+ text = province_norm + city_norm
78
+ if keyword_norm == city_norm:
79
+ score = 100
80
+ elif city_norm.startswith(keyword_norm):
81
+ score = 80
82
+ elif keyword_norm in city_norm:
83
+ score = 60
84
+ elif keyword_norm in text:
85
+ score = 40
86
+ else:
87
+ continue
88
+ results.append((score, city))
89
+ results.sort(key=lambda item: (-item[0], item[1].province_code, item[1].city_slug))
90
+ return [city for _, city in results[:limit]]
91
+
92
+ async def search_banks(self, keyword: str, limit: int = 30) -> list[str]:
93
+ keyword_norm = normalize(keyword)
94
+ if not keyword_norm:
95
+ return []
96
+ bank_names = await self.all_bank_names()
97
+ results = [
98
+ name
99
+ for name in bank_names
100
+ if keyword_norm in normalize(name)
101
+ ]
102
+ results.sort(key=lambda name: (normalize(name).find(keyword_norm), len(name), name))
103
+ return results[:limit]
104
+
105
+ async def all_bank_names(self) -> set[str]:
106
+ indexes = await self.ensure_indexes()
107
+ bank_names: set[str] = set()
108
+ banks = indexes.get("banks", [])
109
+ if isinstance(banks, list):
110
+ for group in banks:
111
+ if not isinstance(group, dict):
112
+ continue
113
+ value = group.get("value", [])
114
+ if not isinstance(value, list):
115
+ continue
116
+ for item in value:
117
+ if not isinstance(item, dict):
118
+ continue
119
+ name = item.get("name")
120
+ if isinstance(name, str) and name.strip():
121
+ bank_names.add(name.strip())
122
+ return bank_names
123
+
124
+ async def split_known_banks(self, banks: list[str]) -> tuple[list[str], list[str]]:
125
+ known_names = await self.all_bank_names()
126
+ known_by_norm = {normalize(name) for name in known_names}
127
+ known: list[str] = []
128
+ unknown: list[str] = []
129
+ for bank in banks:
130
+ target = bank.strip()
131
+ if not target:
132
+ continue
133
+ if normalize(target) in known_by_norm:
134
+ known.append(target)
135
+ else:
136
+ unknown.append(target)
137
+ return known, unknown
138
+
139
+ async def iter_cities(self) -> list[CityRef]:
140
+ indexes = await self.ensure_indexes()
141
+ cities = indexes.get("cities", {})
142
+ refs: list[CityRef] = []
143
+ for province, payload in cities.items():
144
+ if not isinstance(payload, dict):
145
+ continue
146
+ code = payload.get("code")
147
+ city_map = payload.get("cities", {})
148
+ if not isinstance(code, str) or not isinstance(city_map, dict):
149
+ continue
150
+ for city, slug in city_map.items():
151
+ if isinstance(city, str) and isinstance(slug, str):
152
+ refs.append(CityRef(province, code, city, slug))
153
+ return refs
154
+
155
+ async def resolve_city(self, query: str) -> CityRef:
156
+ parts = [part for part in query.strip().split() if part]
157
+ province_kw = ""
158
+ city_kw = query
159
+ if len(parts) >= 2:
160
+ province_kw = parts[0]
161
+ city_kw = "".join(parts[1:])
162
+ city_norm = normalize(city_kw)
163
+ province_norm = normalize(province_kw)
164
+ if not city_norm:
165
+ raise HibankError("请提供城市名。")
166
+
167
+ matches: list[tuple[int, CityRef]] = []
168
+ for city in await self.iter_cities():
169
+ current_city = normalize(city.city)
170
+ current_province = normalize(city.province)
171
+ if province_norm and province_norm not in current_province:
172
+ continue
173
+ if city_norm == current_city:
174
+ score = 100
175
+ elif current_city.startswith(city_norm):
176
+ score = 80
177
+ elif city_norm in current_city:
178
+ score = 60
179
+ elif city_norm in current_province + current_city:
180
+ score = 40
181
+ else:
182
+ continue
183
+ matches.append((score, city))
184
+ if not matches:
185
+ raise HibankError(f"未找到城市:{query}")
186
+ matches.sort(key=lambda item: (-item[0], item[1].province_code, item[1].city_slug))
187
+ return matches[0][1]
188
+
189
+ async def get_city_detail(self, city: CityRef) -> CityDetail:
190
+ cached = cache.read_json(cache.city_cache_path(city.key))
191
+ if isinstance(cached, dict) and cached.get("groups"):
192
+ return CityDetail(
193
+ city=city,
194
+ groups=cached["groups"],
195
+ bank_paths=cached.get("bank_paths", {}),
196
+ from_cache=True,
197
+ )
198
+ html_text = await self._fetch_text(f"/cities/{city.province_code}/{city.city_slug}")
199
+ state = self._parse_state(html_text)
200
+ city_cache = state.get("data", {}).get("cache", {}).get("cities", {})
201
+ groups = city_cache.get(city.key)
202
+ if not isinstance(groups, dict):
203
+ if len(city_cache) == 1:
204
+ groups = next(iter(city_cache.values()))
205
+ if not isinstance(groups, dict):
206
+ raise HibankError(f"{city.city} 银行列表解析失败。")
207
+ normalized_groups = {
208
+ str(category): [str(item) for item in items]
209
+ for category, items in groups.items()
210
+ if isinstance(items, list)
211
+ }
212
+ bank_paths = self._extract_bank_paths(html_text, city)
213
+ cache.write_json(
214
+ cache.city_cache_path(city.key),
215
+ {"city": city.__dict__, "groups": normalized_groups, "bank_paths": bank_paths},
216
+ )
217
+ return CityDetail(city=city, groups=normalized_groups, bank_paths=bank_paths)
218
+
219
+ async def get_branch_detail(
220
+ self,
221
+ city: CityRef,
222
+ bank_query: str,
223
+ page: int = 1,
224
+ ) -> BranchDetail:
225
+ city_detail = await self.get_city_detail(city)
226
+ bank = self.resolve_bank(city_detail, bank_query)
227
+ cached = cache.read_json(cache.branch_cache_path(city.key, bank.name))
228
+ if isinstance(cached, list):
229
+ return BranchDetail(
230
+ city=city,
231
+ bank=bank,
232
+ branches=cached,
233
+ page=page,
234
+ page_size=self.config.hibank_branch_page_size,
235
+ from_cache=True,
236
+ )
237
+ bank_path = bank.path
238
+ if not bank_path.startswith("%"):
239
+ bank_path = quote(bank_path, safe="")
240
+ html_text = await self._fetch_text(
241
+ f"/branches/{city.province_code}/{city.city_slug}/{bank_path}"
242
+ )
243
+ state = self._parse_state(html_text)
244
+ branch_cache = state.get("data", {}).get("cache", {}).get("branches", {})
245
+ branches: Any = branch_cache.get(f"{city.key}_{bank.name}")
246
+ if not isinstance(branches, list) and len(branch_cache) == 1:
247
+ branches = next(iter(branch_cache.values()))
248
+ if not isinstance(branches, list):
249
+ raise HibankError(f"{city.city} {bank.name} 网点列表解析失败。")
250
+ normalized = [
251
+ item
252
+ for item in branches
253
+ if isinstance(item, dict)
254
+ ]
255
+ cache.write_json(cache.branch_cache_path(city.key, bank.name), normalized)
256
+ return BranchDetail(
257
+ city=city,
258
+ bank=bank,
259
+ branches=normalized,
260
+ page=page,
261
+ page_size=self.config.hibank_branch_page_size,
262
+ )
263
+
264
+ def resolve_bank(self, city_detail: CityDetail, query: str) -> BankRef:
265
+ query_norm = normalize(query)
266
+ if not query_norm:
267
+ raise HibankError("请提供银行名。")
268
+ candidates = city_detail.bank_paths
269
+ if not candidates:
270
+ candidates = {
271
+ bank: bank
272
+ for banks in city_detail.groups.values()
273
+ for bank in banks
274
+ }
275
+ matches: list[tuple[int, str, str]] = []
276
+ for name, path in candidates.items():
277
+ name_norm = normalize(name)
278
+ base_norm = normalize(name.split("(", 1)[0])
279
+ if query_norm == name_norm or query_norm == base_norm:
280
+ score = 100
281
+ elif name_norm.startswith(query_norm) or base_norm.startswith(query_norm):
282
+ score = 80
283
+ elif query_norm in name_norm:
284
+ score = 60
285
+ else:
286
+ continue
287
+ matches.append((score, name, path))
288
+ if not matches:
289
+ raise HibankError(f"{city_detail.city.city} 未找到银行:{query}")
290
+ matches.sort(key=lambda item: (-item[0], len(item[1]), item[1]))
291
+ _, name, path = matches[0]
292
+ return BankRef(name=name, path=path)
293
+
294
+ def get_cache_stats(self) -> CacheStats:
295
+ city_count, branch_count = cache.cache_counts()
296
+ return CacheStats(
297
+ indexes_cached=cache.INDEXES_FILE.exists(),
298
+ city_cache_count=city_count,
299
+ branch_cache_count=branch_count,
300
+ cache_dir=str(cache.DATA_DIR),
301
+ )
302
+
303
+ def _indexes_valid(self, indexes: Any) -> bool:
304
+ if not isinstance(indexes, dict):
305
+ return False
306
+ cities = indexes.get("cities")
307
+ banks = indexes.get("banks")
308
+ if not isinstance(cities, dict) or not isinstance(banks, list):
309
+ return False
310
+ sichuan = cities.get("四川省")
311
+ if not isinstance(sichuan, dict):
312
+ return False
313
+ city_map = sichuan.get("cities")
314
+ if not isinstance(city_map, dict):
315
+ return False
316
+ return city_map.get("成都市") == "chengdu"
317
+
318
+ async def clear_cache(self) -> None:
319
+ await asyncio.to_thread(cache.clear_cache)
320
+ self._indexes = None
321
+
322
+ async def _fetch_text(self, path: str) -> str:
323
+ url = self.base_url + path
324
+ return await asyncio.to_thread(self._fetch_text_sync, url)
325
+
326
+ def _fetch_text_sync(self, url: str) -> str:
327
+ headers = {
328
+ "User-Agent": (
329
+ "Mozilla/5.0 AppleWebKit/537.36 "
330
+ "(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
331
+ )
332
+ }
333
+ try:
334
+ with warnings.catch_warnings():
335
+ if not self.config.hibank_verify_ssl:
336
+ warnings.simplefilter("ignore", InsecureRequestWarning)
337
+ response = requests.get(
338
+ url,
339
+ headers=headers,
340
+ timeout=self.config.hibank_timeout,
341
+ verify=self.config.hibank_verify_ssl,
342
+ )
343
+ response.raise_for_status()
344
+ except requests.RequestException as exc:
345
+ raise HibankError(f"请求 HiBank 失败:{exc}") from exc
346
+ response.encoding = "utf-8"
347
+ return response.text
348
+
349
+ def _parse_state(self, html_text: str) -> dict[str, Any]:
350
+ match = STATE_RE.search(html_text)
351
+ if not match:
352
+ raise HibankError("页面状态数据解析失败。")
353
+ try:
354
+ return json.loads(match.group(1))
355
+ except json.JSONDecodeError as exc:
356
+ raise HibankError("页面状态 JSON 解析失败。") from exc
357
+
358
+ def _extract_bank_paths(self, html_text: str, city: CityRef) -> dict[str, str]:
359
+ soup = BeautifulSoup(html_text, "html.parser")
360
+ paths: dict[str, str] = {}
361
+ prefix = f"/branches/{city.province_code}/{city.city_slug}/"
362
+ for anchor in soup.find_all("a", href=True):
363
+ href = str(anchor.get("href"))
364
+ if not href.startswith(prefix):
365
+ continue
366
+ match = BRANCH_HREF_RE.match(href)
367
+ if not match:
368
+ continue
369
+ name = " ".join(anchor.get_text(" ", strip=True).split())
370
+ if not name:
371
+ image = anchor.find("img", alt=True)
372
+ if image is not None:
373
+ name = str(image.get("alt", "")).strip()
374
+ if name:
375
+ paths[html.unescape(name)] = match.group(3)
376
+ return paths
377
+
378
+
379
+ def normalize(value: str) -> str:
380
+ text = str(value).strip().lower()
381
+ for char in (" ", "\t", "\n", "\r", " ", "-", "_"):
382
+ text = text.replace(char, "")
383
+ for suffix in SUFFIXES:
384
+ text = text.replace(suffix, "")
385
+ return text
386
+
387
+
388
+ client = HibankClient()