diff --git a/app/modules/indexer/parser/gazelle.py b/app/modules/indexer/parser/gazelle.py
index b6833db11..7215bef59 100644
--- a/app/modules/indexer/parser/gazelle.py
+++ b/app/modules/indexer/parser/gazelle.py
@@ -140,17 +140,41 @@ class GazelleSiteUserInfo(SiteParserBase):
page_seeding = 0
page_seeding_size = 0
page_seeding_info = []
- seeding_sizes = html.xpath(f'//table[contains(@id, "torrent")]//tr[position()>1]/td[{size_col}]')
- seeding_seeders = html.xpath(f'//table[contains(@id, "torrent")]//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 = size_tools.parse_size(seeding_sizes[i].xpath("string(.)").strip())
- seeders = int(seeding_seeders[i])
-
+ semantic_rows = html.xpath(
+ '//table[contains(@id, "torrent")]//tr['
+ 'contains(concat(" ", normalize-space(@class), " "), " TableTorrent-rowTitle ")]'
+ )
+ if semantic_rows:
+ # GPW 等现代 Gazelle 页面以语义 class 标记列,且标题单元格带 colspan,
+ # 固定列序号无法反映视觉列位置,必须逐行按 class 配对读取。
+ for row in semantic_rows:
+ size_cells = row.xpath(
+ './td[contains(concat(" ", normalize-space(@class), " "), '
+ '" TableTorrent-cellStatSize ")]'
+ )
+ seeder_cells = row.xpath(
+ './td[contains(concat(" ", normalize-space(@class), " "), '
+ '" TableTorrent-cellStatSeeders ")]'
+ )
+ if not size_cells or not seeder_cells:
+ continue
+ size = size_tools.parse_size(size_cells[0].xpath("string(.)").strip())
+ seeders = text_tools.parse_int(seeder_cells[0].xpath("string(.)").strip())
+ page_seeding += 1
page_seeding_size += size
page_seeding_info.append([seeders, size])
+ else:
+ seeding_sizes = html.xpath(f'//table[contains(@id, "torrent")]//tr[position()>1]/td[{size_col}]')
+ seeding_seeders = html.xpath(f'//table[contains(@id, "torrent")]//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 = size_tools.parse_size(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 multi_page:
self.seeding += page_seeding
diff --git a/tests/test_greatposterwall_indexer.py b/tests/test_greatposterwall_indexer.py
new file mode 100644
index 000000000..9efab6f07
--- /dev/null
+++ b/tests/test_greatposterwall_indexer.py
@@ -0,0 +1,140 @@
+from app.modules.indexer.parser.gazelle import GazelleSiteUserInfo
+from app.modules.indexer.spider import SiteSpider
+
+
+GPW_HTML = """
+
+
+
+ |
+
+ |
+
+ today
+ |
+ 1.50 GiB |
+ 12 |
+ 1,234 |
+ 2 |
+
+
+
+"""
+
+
+def _gpw_indexer() -> dict:
+ """构造与站点资源一致的 GPW 搜索配置。"""
+ detail_selector = 'a[href*="torrents.php?id="][href*="torrentid="]'
+ return {
+ "id": "greatposterwall",
+ "name": "海豹",
+ "domain": "https://greatposterwall.com/",
+ "search": {
+ "paths": [
+ {"path": "torrents.php?searchstr={keyword}", "method": "get"}
+ ]
+ },
+ "torrents": {
+ "list": {
+ "selector": "table#torrent_table > tbody > tr.TableTorrent-rowTitle"
+ },
+ "fields": {
+ "id": {
+ "selector": detail_selector,
+ "attribute": "href",
+ "filters": [
+ {"name": "re_search", "args": [r"torrentid=(\d+)", 1]}
+ ],
+ },
+ "title": {"selector": f"{detail_selector} > span.TorrentTitle"},
+ "details": {"selector": detail_selector, "attribute": "href"},
+ "download": {
+ "selector": 'a[href*="torrents.php?action=download"]',
+ "attribute": "href",
+ },
+ "size": {"selector": "td.TableTorrent-cellStatSize"},
+ "seeders": {"selector": "td.TableTorrent-cellStatSeeders"},
+ "leechers": {"selector": "td.TableTorrent-cellStatLeechers"},
+ "grabs": {"selector": "td.TableTorrent-cellStatSnatches"},
+ "date_elapsed": {
+ "selector": "td.TableTorrent-cellStatTime > span",
+ "attribute": "title",
+ "optional": True,
+ },
+ "date": {
+ "text": "{% if fields['date_elapsed'] %}{{ fields['date_elapsed'] }}{% else %}now{% endif %}",
+ "filters": [{"name": "dateparse", "args": "%b %d %Y, %H:%M"}],
+ },
+ },
+ },
+ }
+
+
+def _build_parser() -> GazelleSiteUserInfo:
+ """构造 Gazelle 用户数据解析器测试实例。"""
+ return GazelleSiteUserInfo(
+ site_name="海豹",
+ url="https://greatposterwall.com/",
+ site_cookie="",
+ apikey=None,
+ token=None,
+ )
+
+
+def test_greatposterwall_search_parses_tabletorrent_row(monkeypatch):
+ """GPW 搜索结果应从 TableTorrent 行提取完整种子信息。"""
+ monkeypatch.setattr(
+ "app.modules.indexer.spider.rust_accel.parse_indexer_torrents",
+ lambda **_: None,
+ )
+
+ results = SiteSpider(_gpw_indexer(), keyword="Example").parse(GPW_HTML)
+
+ assert len(results) == 1
+ assert results[0]["title"] == "1080p Example-GROUP"
+ assert results[0]["page_url"].endswith("torrents.php?id=8064&torrentid=115362")
+ assert results[0]["enclosure"].endswith("torrents.php?action=download&id=115362")
+ assert results[0]["size"] == 1610612736
+ assert results[0]["seeders"] == 1234
+ assert results[0]["peers"] == 2
+ assert results[0]["grabs"] == 12
+ assert results[0]["pubdate"] == "2026-08-24 22:51:00"
+
+
+def test_greatposterwall_seeding_uses_semantic_stat_cells():
+ """GPW 标题列使用 colspan 时,做种统计仍应按语义 class 正确配对。"""
+ parser = _build_parser()
+
+ next_page = parser._parse_user_torrent_seeding_info(GPW_HTML)
+
+ assert next_page is None
+ assert parser.seeding == 1
+ assert parser.seeding_size == 1610612736
+ assert parser.seeding_info == [[1234, 1610612736]]
+
+
+def test_gazelle_seeding_keeps_legacy_column_fallback():
+ """没有语义 class 的传统 Gazelle 表格仍应按既有列号统计做种。"""
+ parser = _build_parser()
+ html_text = """
+
+ | Name | Type | Time | Size | Snatches | Seeders | Leechers |
+ | Example | Movie | today | 2 GiB | 3 | 45 | 1 |
+
+ """
+
+ parser._parse_user_torrent_seeding_info(html_text)
+
+ assert parser.seeding == 1
+ assert parser.seeding_size == 2147483648
+ assert parser.seeding_info == [[45, 2147483648]]