Coverage for src/data_api/domain/libraries/services.py: 100%
77 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-13 19:28 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-13 19:28 +0000
1"""
2Libraries domain service.
4Handles library data fetching and search, plus the library RSS feeds
5crawled by NTHU-Data-Scraper.
6"""
8import re
9import ssl
10from datetime import datetime, timedelta
11from typing import Optional
13import httpx
14import truststore
15from bs4 import BeautifulSoup
16from pydantic import TypeAdapter
18from data_api.core.exceptions import (
19 UpstreamException,
20 UpstreamResponseException,
21)
22from data_api.core.upstream import live_client, upstream_errors
23from data_api.data.manager import nthudata
24from data_api.data.nthudata import NTHUDataManager
25from data_api.models.libraries import LibraryLostAndFound, LibrarySpace
27RSS_JSON_PATH = "libraries/rss.json"
28MISSING_LOST_AND_FOUND_TABLE = "Lost-and-found results table is missing"
29DEFAULT_HEADERS = {
30 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
31}
32SPACE_ADAPTER = TypeAdapter(list[LibrarySpace])
33ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
36def parse_lost_items(html: str) -> list[dict[str, str]]:
37 """Parse complete results, distinguishing an empty page from a broken one."""
38 soup = BeautifulSoup(html, "html.parser")
39 table = soup.find("table")
40 if table is None:
41 content = soup.find(id="content")
42 if content is None:
43 raise UpstreamResponseException(MISSING_LOST_AND_FOUND_TABLE)
44 heading = content.find("h1")
45 if heading is None:
46 raise UpstreamResponseException(MISSING_LOST_AND_FOUND_TABLE)
47 if "Lost and Found System" in heading.get_text() and re.search(
48 r"\u76ee\u524d\u7121\u8cc7\u6599\s*!!", content.get_text()
49 ):
50 return []
51 raise UpstreamResponseException(MISSING_LOST_AND_FOUND_TABLE)
53 rows = table.find_all("tr")
54 if not rows:
55 raise UpstreamResponseException("Lost-and-found table has no header")
56 titles = [cell.get_text(strip=True) for cell in rows[0].find_all(["td", "th"])]
57 column_names = set(titles)
58 if column_names != set(LibraryLostAndFound.model_fields) or len(titles) != len(column_names):
59 raise UpstreamResponseException("Lost-and-found table has invalid columns")
61 items = []
62 for row in rows[1:]:
63 cells = [
64 re.sub(r"\s+", " ", cell.get_text().strip()) for cell in row.find_all(["td", "th"])
65 ]
66 if len(cells) != len(titles):
67 raise UpstreamResponseException("Lost-and-found row has invalid cells")
68 items.append(dict(zip(titles, cells)))
69 return items
72class LibrariesService:
73 """Service for library data operations."""
75 def __init__(self, manager: NTHUDataManager = nthudata):
76 self.manager = manager
77 self.client: httpx.AsyncClient | None = None
79 async def get_space_availability(self) -> list[dict]:
80 """Get validated live library space data."""
81 with upstream_errors():
82 async with live_client(self.client, verify=ctx) as client:
83 response = await client.get(
84 "https://libsms.lib.nthu.edu.tw/RWDAPI_New/GetDevUseStatus.aspx",
85 headers=DEFAULT_HEADERS,
86 )
87 response.raise_for_status()
88 data = response.json()
90 if not isinstance(data, dict) or not isinstance(data.get("resmsg"), str):
91 raise UpstreamResponseException("Library space response has no result message")
92 if data["resmsg"] != "\u6210\u529f":
93 raise UpstreamException("Library space service reported a failure")
94 rows = data.get("rows")
95 if not isinstance(rows, list):
96 raise UpstreamResponseException("Library space response has no rows list")
97 SPACE_ADAPTER.validate_python(rows)
98 return rows
100 async def get_lost_and_found_items(self) -> list[dict[str, str]]:
101 """Get all live lost items from the last six months."""
102 date_end = datetime.now()
103 date_start = date_end - timedelta(days=6 * 30)
104 post_data = {
105 "place": "0",
106 "date_start": date_start.strftime("%Y-%m-%d"),
107 "date_end": date_end.strftime("%Y-%m-%d"),
108 "catalog": "ALL",
109 "keyword": "",
110 "SUMIT": "\u9001\u51fa",
111 }
112 with upstream_errors():
113 async with live_client(self.client, verify=ctx) as client:
114 response = await client.post(
115 "https://adage.lib.nthu.edu.tw/find/search_it.php",
116 data=post_data,
117 headers=DEFAULT_HEADERS,
118 )
119 response.raise_for_status()
120 return parse_lost_items(response.text)
122 async def get_rss_items(self, rss_type: str) -> tuple[Optional[str], Optional[list[dict]]]:
123 """Get the items of one library RSS feed."""
124 commit_hash, rss_data = await self.manager.get(RSS_JSON_PATH)
125 return commit_hash, rss_data.get(rss_type)
128# Global service instance
129libraries_service = LibrariesService()