# -*- coding: utf-8 -*- """ 解析 NexusPHP 站点的用户资料、流量和做种信息。 """ import re from typing import Any, Optional, Union from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from lxml import etree from app.foundation import temporal as time_tools from app.foundation import text as text_tools from app.foundation.dom import DomUtils from app.modules.indexer.parser import SitePage, SiteParserBase, SiteSchema from app.runtime.log import logger class NexusPhpSiteUserInfo(SiteParserBase): """ NexusPHP 站点用户信息解析器。 """ schema = SiteSchema.NexusPhp _summary_seeding: Optional[int] = None _summary_seeding_size: Optional[int] = None def _parse_site_page(self, html_text: SitePage) -> None: """ 识别用户详情入口,并为定制站点选择已知的做种接口。 """ html_text = self._prepare_html_text(html_text) # NexusPHP v1.10+ 使用 UUID 标识用户,详情链接参数为 uuid user_detail = re.search(r"userdetails\.php\?(?:id|uuid)=([0-9A-Za-z_-]+)", html_text) if user_detail and user_detail.group(1).strip(): self._user_detail_page = user_detail.group().strip().lstrip('/') self.userid = user_detail.group(1).strip() self._torrent_seeding_page = self._build_torrent_seeding_page() else: user_detail = re.search(r"(userdetails)", html_text) if user_detail and user_detail.group().strip(): self._user_detail_page = user_detail.group().strip().lstrip('/') self.userid = None self._torrent_seeding_page = None @staticmethod def _is_uuid(user_id: Union[str, int, None]) -> bool: """ 判断用户标识是否为 UUID 形式。 使用 UUID 的 NexusPHP 站点将做种列表接口参数改名为 useruuid。 """ return bool(re.fullmatch( r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", str(user_id or "").strip() )) def _build_torrent_seeding_page(self) -> str: """ 构造做种列表接口地址。 :return: 做种列表地址,无有效用户标识时返回空字符串 """ if not self._has_valid_userid(): return "" site_domain = self._site_domain.lower().removeprefix("www.") if site_domain in {"pterclub.net", "pterclub.com"}: # 猫站的列表入口要求完整的浏览器导航请求头,仅作用于该站用户数据。 headers: dict[str, Optional[str]] = { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "same-origin", } self._torrent_seeding_headers = headers self._user_detail_headers = dict(headers) return f"getusertorrentlist.php?userid={self.userid}&type=seeding" if site_domain == "keepfrds.com": return f"torrents.php?option-torrents=3&userid={self.userid}" if site_domain == "pt.sjtu.edu.cn": return f"viewtorrentsdetail.php?id={self.userid}" user_id_param = "useruuid" if self._is_uuid(self.userid) else "userid" return f"getusertorrentlistajax.php?{user_id_param}={self.userid}&type=seeding" def _parse_message_unread(self, html_text: str) -> None: """ 解析未读短消息数量。 :param html_text: 当前页面或 API 响应文本 """ html = etree.HTML(html_text) try: if not DomUtils.has_child_elements(html): return message_labels = html.xpath('//a[@href="messages.php"]/..') message_labels.extend(html.xpath('//a[contains(@href, "messages.php")]/..')) if message_labels: message_text = message_labels[0].xpath("string(.)") logger.debug(f"{self._site_name} 消息原始信息 {message_text}") message_unread_match = re.findall(r"[^Date](信息箱\s*|\((?![^)]*:)|你有\xa0)(\d+)", message_text) if message_unread_match and len(message_unread_match[-1]) == 2: self.message_unread = text_tools.parse_int(message_unread_match[-1][1]) elif message_text.isdigit(): self.message_unread = text_tools.parse_int(message_text) finally: if html is not None: del html def _parse_user_base_info(self, html_text: SitePage) -> None: """ 解析用户基本信息。 """ if isinstance(html_text, dict): raise TypeError("expected string or bytes-like object, got 'dict'") # 合并解析,减少额外请求调用 self._parse_user_traffic_info(html_text) self._user_traffic_page = None self._parse_message_unread(html_text) html = etree.HTML(html_text) try: if not DomUtils.has_child_elements(html): return user_level = self._parse_user_level_marker(html.xpath("string(.)"), self.userid) if user_level: self.user_level = user_level ret = html.xpath(f'//a[contains(@href, "userdetails") and contains(@href, "{self.userid}")]//b//text()') if ret: self.username = str(ret[0]) return ret = html.xpath(f'//a[contains(@href, "userdetails") and contains(@href, "{self.userid}")]//text()') if ret: self.username = str(ret[0]) ret = html.xpath('//a[contains(@href, "userdetails")]//strong//text()') finally: if html is not None: del html if ret: self.username = str(ret[0]) return def _parse_user_traffic_info(self, html_text: str) -> None: """ 解析用户流量信息。 """ html_text = self._prepare_html_text(html_text) upload_match = re.search(r"[^总]上[传傳]量?[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html_text, re.IGNORECASE) self.upload = self.num_filesize(upload_match.group(1).strip()) if upload_match else 0 download_match = re.search(r"[^总子影力]下[载載]量?[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+[KMGTPI]*B)", html_text, re.IGNORECASE) self.download = self.num_filesize(download_match.group(1).strip()) if download_match else 0 ratio_match = re.search(r"分享率[::_<>/a-zA-Z-=\"'\s#;]+([\d,.\s]+)", html_text) # 计算分享率 calc_ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3) # 优先使用页面上的分享率 self.ratio = text_tools.parse_float(ratio_match.group(1)) if ( ratio_match and ratio_match.group(1).strip()) else calc_ratio leeching_match = re.search(r"(Torrents leeching|下载中)[\u4E00-\u9FA5\D\s]+(\d+)[\s\S]+<", html_text) self.leeching = text_tools.parse_int(leeching_match.group(2)) if leeching_match and leeching_match.group( 2).strip() else 0 html = etree.HTML(html_text) try: has_ucoin, self.bonus = self._parse_ucoin(html) if has_ucoin: return bonus_links = html.xpath('//a[contains(@href,"mybonus")]') if html is not None else [] for bonus_link in bonus_links: # 魔力值直接写在链接文本中 for bonus_text in bonus_link.xpath('text()'): bonus_match = re.search(r"([\d,.]+)", str(bonus_text).strip()) if bonus_match and bonus_match.group(1).strip(): self.bonus = text_tools.parse_float(bonus_match.group(1)) return # 数值嵌在链接内部的元素中(憨憨:
13,853,582
) bonus_text = bonus_link.xpath("string(.)").strip() bonus_match = re.fullmatch(r"[\s\[((【]*([\d][\d,.]*)[\s\]))】]*", bonus_text) if bonus_match and bonus_match.group(1).strip(): self.bonus = text_tools.parse_float(bonus_match.group(1)) return # PTT-NP 等站点的链接文本只有“使用&说明”,数值紧跟在链接之后 bonus_match = re.search(r"[::]\s*([\d,.]+)", bonus_link.tail or "") if bonus_match and bonus_match.group(1).strip(): self.bonus = text_tools.parse_float(bonus_match.group(1)) return for bonus_link in bonus_links: # 兜底解析链接附近容器中的魔力值,容器必须临近且文本紧凑, # 避免把公告、新闻等大段文本里的无关数字当作魔力值 bonus_containers = bonus_link.xpath('ancestor::*[position() <= 4][contains(., "魔力值")]') for bonus_container in bonus_containers: bonus_text = bonus_container.xpath("string(.)") if len(bonus_text) > 200: continue bonus_match = re.search(r"魔力值.*?[::]\s*([\d,.]+)", bonus_text, flags=re.S) if bonus_match and bonus_match.group(1).strip(): self.bonus = text_tools.parse_float(bonus_match.group(1)) return bonus_match = re.search(r"mybonus.[\[\]::<>/a-zA-Z_\-=\"'\s#;.(使用魔力值豆]+\s*([\d,.]+)[<()&\s]", html_text) try: if bonus_match and bonus_match.group(1).strip(): self.bonus = text_tools.parse_float(bonus_match.group(1)) return bonus_match = re.search(r"(?:魔力值|\])[\[\]::<>/a-zA-Z_\-=\"'\s#;]+\s*([\d,.]+|\"[\d,.]+\")[<>()&\s]", html_text, flags=re.S) if bonus_match and bonus_match.group(1).strip(): self.bonus = text_tools.parse_float(bonus_match.group(1).strip('"')) except Exception as err: logger.error(f"{self._site_name} 解析魔力值出错, 错误信息: {str(err)}") finally: if html is not None: del html @staticmethod def _parse_ucoin(html: etree._Element) -> tuple[bool, float]: """ 解析站点金币、银币和铜币,并统一换算为铜币。 :param html: 包含货币标记的页面节点 :return: 是否取得非零货币值及换算结果;没有有效值时返回 False 和 0.0 """ if DomUtils.has_child_elements(html): gold, silver, copper = None, None, None golds = html.xpath('//span[@class = "ucoin-symbol ucoin-gold"]//text()') if golds: gold = text_tools.parse_float(str(golds[-1])) silvers = html.xpath('//span[@class = "ucoin-symbol ucoin-silver"]//text()') if silvers: silver = text_tools.parse_float(str(silvers[-1])) coppers = html.xpath('//span[@class = "ucoin-symbol ucoin-copper"]//text()') if coppers: copper = text_tools.parse_float(str(coppers[-1])) if gold or silver or copper: gold = gold if gold else 0 silver = silver if silver else 0 copper = copper if copper else 0 return True, gold * 100 * 100 + silver * 100 + copper return False, 0.0 def _parse_seeding_pages(self) -> None: """ 优先使用完整做种列表统计,列表为空或解析不完整时回退到用户详情页统计。 """ profile_seeding = self.seeding profile_seeding_size = self.seeding_size profile_seeding_info = list(self.seeding_info) # 用户详情页可能只显示部分做种记录,先暂存,避免与完整列表相加造成重复。 self.seeding = 0 self.seeding_size = 0 self.seeding_info.clear() try: super()._parse_seeding_pages() except Exception: self.seeding = profile_seeding self.seeding_size = profile_seeding_size self.seeding_info = profile_seeding_info raise if (not self.seeding or not self.seeding_size) and (profile_seeding or profile_seeding_size): self.seeding = profile_seeding self.seeding_size = profile_seeding_size if profile_seeding_info: self.seeding_info = profile_seeding_info # 定制站点明细可能只返回部分记录,明确的汇总值优先,明细仍用于做种人数分布。 if self._summary_seeding is not None: self.seeding = self._summary_seeding if self._summary_seeding_size is not None: self.seeding_size = self._summary_seeding_size def _parse_seeding_summary(self, html: Any) -> None: """ 读取已知定制站点的明确汇总,不把缺失体积推算为真实值。 """ if self._site_domain.lower().removeprefix("www.") not in { "pterclub.net", "pterclub.com", "keepfrds.com", "pt.sjtu.edu.cn" }: return page_text = " ".join(html.xpath('//text()[not(ancestor::script) and not(ancestor::style)]')) count_match = re.search(r"当前做种\s*[::]?\s*([\d,]+)", page_text) if not count_match and self._site_domain.lower().removeprefix("www.") in {"pterclub.net", "pterclub.com"}: count_match = re.search(r"正在做种的种子\s*[((]\s*共\s*([\d,]+)\s*个", page_text) size_match = re.search(r"做种大小\s*[::]?\s*([\d,.]+\s*[KMGTPI]i?B)", page_text, re.I) if count_match: self._summary_seeding = text_tools.parse_int(count_match.group(1).replace(",", "")) self.seeding = self._summary_seeding if size_match: self._summary_seeding_size = self.num_filesize(size_match.group(1)) self.seeding_size = self._summary_seeding_size def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]: """ 做种相关信息。 :param html_text: 当前页面或 API 响应文本 :param multi_page: 是否多页数据 :return: 下页地址 """ if not html_text: logger.warning(f"{self._site_name} 做种接口未返回内容,请检查站点接口和访问权限") return None html = etree.HTML(str(html_text).replace(r'\/', '/')) try: if not DomUtils.has_child_elements(html): return None self._parse_seeding_summary(html) if self._site_domain.lower().removeprefix("www.") == "pt.sjtu.edu.cn": if self._summary_seeding is None: logger.warning(f"{self._site_name} 做种汇总为空或无法解析,请检查站点接口和访问权限") return None # 首页存在扩展链接,使用扩展链接 seeding_url_text = html.xpath('//a[contains(@href,"torrents.php") ' 'and contains(@href,"seeding")]/@href') if multi_page is False and seeding_url_text and seeding_url_text[0].strip(): self._torrent_seeding_page = seeding_url_text[0].strip() return self._torrent_seeding_page size_col, seeders_col = self._seeding_columns(html) page_seeding = 0 page_seeding_size = 0 page_seeding_info = [] site_domain = self._site_domain.lower().removeprefix("www.") if site_domain in {"pterclub.net", "pterclub.com"}: # 猫站完整做种列表按 td[2]/td[4]/td[5] 展示种子、体积和做种人数。 pterclub_rows = html.xpath( '//*[@id="outer"]/table/tbody/tr | //*[@id="outer"]/table/tr' '|//table[contains(concat(" ", normalize-space(@class), " "), " torrents ")]/tbody/tr' '|//table[contains(concat(" ", normalize-space(@class), " "), " torrents ")]/tr' ) for row in pterclub_rows: columns = row.xpath('./td | ./th') if len(columns) < 5 or not columns[1].xpath('.//a'): continue size = self.num_filesize(columns[3].xpath("string(.)").strip()) if not size: continue seeders = text_tools.parse_int(columns[4].xpath("string(.)").strip()) page_seeding += 1 page_seeding_size += size page_seeding_info.append([seeders, size]) else: # 如果 table class="torrents",则限制在种子表中,避免解析页面其他表格。 table_class = '//table[@class="torrents"]' if html.xpath('//table[@class="torrents"]') else '' seeding_sizes = html.xpath(f'{table_class}//tr[position()>1]/td[{size_col}]') seeding_seeders = html.xpath(f'{table_class}//tr[position()>1]/td[{seeders_col}]/b/a/text()') if not seeding_seeders: seeding_seeders = html.xpath(f'{table_class}//tr[position()>1]/td[{seeders_col}]//text()') if seeding_sizes and seeding_seeders: page_seeding = len(seeding_sizes) for i in range(0, len(seeding_sizes)): size = self.num_filesize(seeding_sizes[i].xpath("string(.)").strip()) seeders = text_tools.parse_int(seeding_seeders[i]) page_seeding_size += size page_seeding_info.append([seeders, size]) if not page_seeding and not multi_page and self._summary_seeding is None: logger.warning(f"{self._site_name} 做种列表为空或无法解析,请检查站点接口和访问权限") self.seeding += page_seeding self.seeding_size += page_seeding_size self.seeding_info.extend(page_seeding_info) next_page = self._seeding_next_page(html) finally: if html is not None: del html return next_page def _seeding_columns(self, html: Any) -> tuple[int, int]: """ 优先使用表头标记,朋友的无标记模板使用已确认的固定列位。 """ size_col = 3 seeders_col = 4 # 搜索size列 size_col_xpath = '//tr[position()=1]/' \ 'td[(img[@class="size"] and img[@alt="size"])' \ ' or (text() = "大小")' \ ' or (a/img[@class="size" and @alt="size"])]' if html.xpath(size_col_xpath): size_col = len(html.xpath(f'{size_col_xpath}/preceding-sibling::td')) + 1 # 搜索seeders列 seeders_col_xpath = '//tr[position()=1]/' \ 'td[(img[@class="seeders"] and img[@alt="seeders"])' \ ' or (text() = "在做种")' \ ' or (a/img[@class="seeders" and @alt="seeders"])]' if html.xpath(seeders_col_xpath): seeders_col = len(html.xpath(f'{seeders_col_xpath}/preceding-sibling::td')) + 1 if self._site_domain.lower().removeprefix("www.") == "keepfrds.com": # 朋友没有列标记,第四列为做种时长,第五、六列才是体积和做种人数。 size_col, seeders_col = 5, 6 return size_col, seeders_col def _seeding_next_page(self, html: Any) -> Optional[str]: """ 筛除详情链接并补齐下一页的用户标识。 """ # 是否存在下页数据 next_page = None next_page_text = html.xpath( '//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁") or contains(.//text(), ">")]/@href') # 防止识别到详情页 while next_page_text: next_page = next_page_text.pop().strip() if not next_page.startswith('details.php'): break next_page = None # 补齐分页地址所需的用户标识,避免请求错误的做种列表。 if next_page: next_page = self._fixup_next_page_url(next_page, self.userid) return next_page def _fixup_next_page_url(self, next_page: str, userid: Union[str, int, None]) -> Optional[str]: """ 修正做种下一页地址,无法补齐用户 ID 时停止翻页。 :param next_page: 页面中解析出的下一页地址 :param userid: 当前站点用户 ID :return: 修正后的下一页地址,无法构造时返回 None """ parsed_url = urlsplit(next_page) query_params: dict[str, Union[str, int]] = dict(parse_qsl(parsed_url.query, keep_blank_values=True)) if any(query_params.get(key) for key in ("userid", "useruuid")): return next_page if not userid: return None query_params["useruuid" if self._is_uuid(userid) else "userid"] = userid query_params.setdefault("type", "seeding") return urlunsplit(parsed_url._replace(query=urlencode(query_params))) def _parse_user_detail_info(self, html_text: str) -> None: """ 解析用户额外信息,加入时间,等级。 :param html_text: 当前页面或 API 响应文本 """ html = etree.HTML(html_text) try: if not DomUtils.has_child_elements(html): return self._parse_seeding_summary(html) self._get_user_level(html) self._fixup_traffic_info(html) # 加入日期 join_at_text = html.xpath( '//tr/td[text()="加入日期" or text()="注册日期" or *[text()="加入日期"]]/following-sibling::td[1]//text()' '|//div/b[text()="加入日期"]/../text()' '|//*[@id="outer"]/table/tr/td/div/div[1]/div[2]/div[3]/span[1]/span/@title') if join_at_text: self.join_at = time_tools.normalize_datetime(join_at_text[0].split(' (')[0].strip()) # 做种体积 & 做种数 # seeding 页面获取不到的话,此处再获取一次 seeding_tables = html.xpath( '//tr/td[normalize-space(.)="当前上传" or normalize-space(.)="当前做种"]' '/following-sibling::td[1]//table' ) tmp_seeding = 0 tmp_seeding_size = 0 tmp_seeding_info = [] for seeding_table in seeding_tables: # 猫站使用“当前做种”作为面板行名;该表第 4、5 列分别是体积和做种人数。 seeding_rows = seeding_table.xpath('./tr | ./tbody/tr') for seeding_row in seeding_rows: columns = seeding_row.xpath('./td | ./th') if len(columns) < 5: continue size = self.num_filesize(columns[3].xpath("string(.)").strip()) # 无表头的做种表以有效体积列识别数据行,避免把表头计入数量。 if not size: continue seeders = text_tools.parse_int(columns[4].xpath("string(.)").strip()) tmp_seeding += 1 tmp_seeding_size += size tmp_seeding_info.append([seeders, size]) if not self.seeding_size: self.seeding_size = tmp_seeding_size if not self.seeding: self.seeding = tmp_seeding if not self.seeding_info: self.seeding_info = tmp_seeding_info seeding_sizes = html.xpath('//tr/td[text()="做种统计"]/following-sibling::td[1]//text()') if seeding_sizes: seeding_match = re.search(r"总做种数:\s+(\d+)", seeding_sizes[0], re.IGNORECASE) seeding_size_match = re.search(r"总做种体积:\s+([\d,.\s]+[KMGTPI]*B)", seeding_sizes[0], re.IGNORECASE) tmp_seeding = text_tools.parse_int(seeding_match.group(1)) if ( seeding_match and seeding_match.group(1)) else 0 tmp_seeding_size = self.num_filesize( seeding_size_match.group(1).strip()) if seeding_size_match else 0 if not self.seeding_size: self.seeding_size = tmp_seeding_size if not self.seeding: self.seeding = tmp_seeding self._fixup_torrent_seeding_page(html) finally: if html is not None: del html def _fixup_torrent_seeding_page(self, html: etree._Element) -> None: """ 从页面入口或脚本参数补齐做种列表地址和用户标识。 :param html: 用户详情页面节点,缺失时保留当前地址 """ # 单独的种子页面 seeding_url_text = html.xpath('//a[contains(@href,"getusertorrentlist.php") ' 'and contains(@href,"seeding")]/@href') if seeding_url_text: self._torrent_seeding_page = seeding_url_text[0].strip() # 从 JavaScript 调用中获取用户 ID。 seeding_url_text = html.xpath('//a[contains(@href, "javascript: getusertorrentlistajax") ' 'and contains(@href,"seeding")]/@href') csrf_text = html.xpath('//meta[@name="x-csrf"]/@content') if not self._torrent_seeding_page and seeding_url_text: user_js = re.search(r"javascript:\s*getusertorrentlistajax\(\s*'([^']+)'", seeding_url_text[0]) if user_js and user_js.group(1).strip(): self.userid = user_js.group(1).strip() self._torrent_seeding_page = self._build_torrent_seeding_page() elif seeding_url_text and csrf_text: if csrf_text[0].strip(): self._torrent_seeding_page \ = "ajax_getusertorrentlist.php" self._torrent_seeding_params = {'userid': self.userid, 'type': 'seeding', 'csrf': csrf_text[0].strip()} @staticmethod def _parse_user_level_marker(page_text: str, userid: Union[str, int, None]) -> Optional[str]: """ 解析 PTT-NP 首页中 UID 标记后的通用用户等级。 """ user_id_pattern = re.escape(str(userid)) if userid else r"\d+" user_level_match = re.search( rf"\[\s*UID\s*=\s*{user_id_pattern}\s*\]\s*\[\s*\([^\)\]]+\)\s*([^\]]+?)\s*\]", page_text, flags=re.IGNORECASE, ) return user_level_match.group(1).strip() if user_level_match else None def _get_user_level(self, html: etree._Element) -> None: """ 从用户详情页或首页中解析用户等级。 """ # 等级获取同一行等级数据,图片格式等级取 title 信息,否则取文本信息。 user_levels_text = html.xpath('//tr/td[text()="等級" or text()="等级" or *[text()="等级"]]/' 'following-sibling::td[1]/img[1]/@title') if user_levels_text: self.user_level = user_levels_text[0].strip() return user_levels_text = html.xpath('//tr/td[text()="等級" or text()="等级"]/' 'following-sibling::td[1 and not(img)]' '|//tr/td[text()="等級" or text()="等级"]/' 'following-sibling::td[1 and img[not(@title)]]') if user_levels_text: self.user_level = user_levels_text[0].xpath("string(.)").strip() return user_levels_text = html.xpath('//tr/td[text()="等級" or text()="等级"]/' 'following-sibling::td[1]') if user_levels_text: self.user_level = user_levels_text[0].xpath("string(.)").strip() return user_levels_text = html.xpath('//a[contains(@href, "userdetails")]/text()') if not self.user_level and user_levels_text: for user_level_text in user_levels_text: user_level_match = re.search(r"\[(.*)]", user_level_text) if user_level_match and user_level_match.group(1).strip(): self.user_level = user_level_match.group(1).strip() break user_level = self._parse_user_level_marker(html.xpath("string(.)"), self.userid) if user_level: self.user_level = user_level def _parse_message_unread_links(self, html_text: str, msg_links: list[str]) -> Optional[str]: """ 解析未读消息链接及下一页入口。 """ html = etree.HTML(html_text) try: if not DomUtils.has_child_elements(html): return None message_links = html.xpath('//tr[not(./td/img[@alt="Read"])]/td/a[contains(@href, "viewmessage")]/@href') msg_links.extend(message_links) # 是否存在下页数据 next_page = None next_page_text = html.xpath('//a[contains(.//text(), "下一页") or contains(.//text(), "下一頁")]/@href') if next_page_text: next_page = next_page_text[-1].strip() finally: if html is not None: del html return next_page def _parse_message_content(self, html_text: str) -> tuple[Optional[str], Optional[str], Optional[str]]: """ 解析消息标题、时间和正文。 """ html = etree.HTML(html_text) try: if not DomUtils.has_child_elements(html): return None, None, None # 标题 message_head_text = None message_head = html.xpath('//h1/text()' '|//div[@class="layui-card-header"]/span[1]/text()') if message_head: message_head_text = message_head[-1].strip() # 消息时间 message_date_text = None message_date = html.xpath('//h1/following-sibling::table[.//tr/td[@class="colhead"]]//tr[2]/td[2]' '|//div[@class="layui-card-header"]/span[2]/span[2]') if message_date: message_date_text = message_date[0].xpath("string(.)").strip() # 消息内容 message_content_text = None message_content = html.xpath('//h1/following-sibling::table[.//tr/td[@class="colhead"]]//tr[3]/td' '|//div[contains(@class,"layui-card-body")]') if message_content: message_content_text = message_content[0].xpath("string(.)").strip() finally: if html is not None: del html return message_head_text, message_date_text, message_content_text def _fixup_traffic_info(self, html: etree._Element) -> None: """ 从详情表格补读为零的流量、分享率和魔力值,非零值保持不变。 """ if not self.upload: upload_text = self._traffic_table_value(html, r"上[传傳]量?") if upload_text is not None: self.upload = self.num_filesize(upload_text) if not self.download: download_text = self._traffic_table_value(html, r"下[载載]量?") if download_text is not None: self.download = self.num_filesize(download_text) if not self.ratio: ratio_text = self._traffic_table_value(html, "分享率") if ratio_text is not None: ratio = text_tools.parse_float(ratio_text) else: ratio = round(self.upload / self.download, 3) if self.download else 0.0 self.ratio = ratio # 用户详情表中的魔力值可补齐首页缺失的数据。 if not self.bonus: bonus_text = html.xpath('//tr/td[text()="魔力值" or text()="猫粮"]/following-sibling::td[1]/text()') if bonus_text: self.bonus = text_tools.parse_float(bonus_text[0].strip()) @staticmethod def _traffic_table_value(html: Any, field: str) -> Optional[str]: """ 按单元格字段名读取数值,支持同格或相邻列布局,排除“实际”流量。 """ for cell in html.xpath('//tr/td[not(.//td)]'): cell_text = " ".join(cell.xpath("string(.)").split()) field_match = re.fullmatch(rf"{field}\s*[::]?\s*(.*)", cell_text) if not field_match: continue value = field_match.group(1).strip() if not value: siblings = cell.xpath('following-sibling::td[1]') value = " ".join(siblings[0].xpath("string(.)").split()) if siblings else "" value_match = re.fullmatch(r"([\d,.]+(?:\s*[KMGTPI]i?B)?)(?:\s*[((].*)?", value, re.I) if value_match: return value_match.group(1) return None