From b0b2065e444af6d0869369af2c3e7ce54cfeb591 Mon Sep 17 00:00:00 2001 From: Harold <8866033@gmail.com> Date: Sun, 26 Apr 2026 08:50:15 +0800 Subject: [PATCH] feat: add feikuai category and search parsing --- py/tests/test_飞快TV.py | 48 +++++++++++++++++++++ py/飞快TV.py | 95 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+) diff --git a/py/tests/test_飞快TV.py b/py/tests/test_飞快TV.py index 6d6f103..d9542b8 100644 --- a/py/tests/test_飞快TV.py +++ b/py/tests/test_飞快TV.py @@ -1,6 +1,7 @@ import unittest from importlib.machinery import SourceFileLoader from pathlib import Path +from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] @@ -28,3 +29,50 @@ class TestFeikuaiSpider(unittest.TestCase): def test_home_video_content_returns_empty_list(self): self.assertEqual(self.spider.homeVideoContent(), {"list": []}) + + @patch.object(Spider, "_request_html") + def test_category_content_parses_short_vod_id(self, mock_request_html): + mock_request_html.return_value = """ + + +
更新至10集
+
+ """ + result = self.spider.categoryContent("2", "3", False, {}) + self.assertEqual( + mock_request_html.call_args.args[0], + "https://feikuai.tv/vodshow/2--------3---.html", + ) + self.assertEqual( + result["list"], + [ + { + "vod_id": "/voddetail/12345.html", + "vod_name": "分类影片", + "vod_pic": "https://feikuai.tv/cover.jpg", + "vod_remarks": "更新至10集", + } + ], + ) + self.assertNotIn("pagecount", result) + + @patch.object(Spider, "_request_html") + def test_search_content_parses_cards_and_blank_keyword(self, mock_request_html): + blank = self.spider.searchContent("", False, "1") + self.assertEqual(blank, {"page": 1, "limit": 0, "total": 0, "list": []}) + mock_request_html.assert_not_called() + + mock_request_html.return_value = """ +
+ +
+
搜索命中
+
HD
+
+ """ + result = self.spider.searchContent("繁花", False, "2") + self.assertEqual( + mock_request_html.call_args.args[0], + "https://feikuai.tv/label/search_ajax.html?wd=%E7%B9%81%E8%8A%B1&by=time&order=desc&page=2", + ) + self.assertEqual(result["list"][0]["vod_id"], "/voddetail/67890.html") diff --git a/py/飞快TV.py b/py/飞快TV.py index 5ed55b1..89640ab 100644 --- a/py/飞快TV.py +++ b/py/飞快TV.py @@ -1,5 +1,7 @@ # coding=utf-8 +import re import sys +from urllib.parse import quote, urljoin from base.spider import Spider as BaseSpider @@ -38,3 +40,96 @@ class Spider(BaseSpider): def homeVideoContent(self): return {"list": []} + + def _build_url(self, value): + raw = str(value or "").strip() + if not raw: + return "" + if raw.startswith(("http://", "https://")): + return raw + if raw.startswith("//"): + return "https:" + raw + return urljoin(self.host + "/", raw) + + def _clean_text(self, text): + return re.sub(r"\s+", " ", str(text or "").replace("\xa0", " ")).strip() + + def _request_html(self, path_or_url): + target = path_or_url if str(path_or_url).startswith("http") else self._build_url(path_or_url) + response = self.fetch(target, headers=self.headers, timeout=10) + if response.status_code != 200: + return "" + return str(response.text or "") + + def _parse_category_cards(self, html): + root = self.html(html or "") + if root is None: + return [] + items = [] + for node in root.xpath("//a[contains(@class,'module-poster-item')]"): + vod_id = self._clean_text("".join(node.xpath("./@href"))) + vod_name = self._clean_text("".join(node.xpath("./@title"))) or self._clean_text( + "".join(node.xpath(".//*[contains(@class,'module-poster-item-title')][1]//text()")) + ) + vod_pic = self._clean_text( + "".join(node.xpath(".//img[contains(@class,'lazy')][1]/@data-original")) + ) + vod_remarks = self._clean_text( + "".join(node.xpath(".//*[contains(@class,'module-item-note')][1]//text()")) + ) + if vod_id and vod_name: + items.append( + { + "vod_id": vod_id, + "vod_name": vod_name, + "vod_pic": self._build_url(vod_pic), + "vod_remarks": vod_remarks, + } + ) + return items + + def _parse_search_cards(self, html): + root = self.html(html or "") + if root is None: + return [] + items = [] + for node in root.xpath( + "//*[contains(@class,'module-card-item') and contains(@class,'module-item')]" + ): + vod_id = self._clean_text( + "".join(node.xpath(".//a[contains(@class,'module-card-item-poster')][1]/@href")) + ) + vod_name = self._clean_text( + "".join(node.xpath(".//*[contains(@class,'module-card-item-title')][1]//strong/text()")) + ) + vod_pic = self._clean_text( + "".join(node.xpath(".//*[contains(@class,'module-item-pic')]//img[1]/@data-original")) + ) + vod_remarks = self._clean_text( + "".join(node.xpath(".//*[contains(@class,'module-item-note')][1]//text()")) + ) + if vod_id and vod_name: + items.append( + { + "vod_id": vod_id, + "vod_name": vod_name, + "vod_pic": self._build_url(vod_pic), + "vod_remarks": vod_remarks, + } + ) + return items + + def categoryContent(self, tid, pg, filter, extend): + page = max(1, int(pg)) + url = self.host + f"/vodshow/{tid}--------{page}---.html" + items = self._parse_category_cards(self._request_html(url)) + return {"page": page, "limit": len(items), "total": len(items), "list": items} + + def searchContent(self, key, quick, pg="1"): + page = max(1, int(pg)) + keyword = self._clean_text(key) + if not keyword: + return {"page": page, "limit": 0, "total": 0, "list": []} + url = self.host + "/label/search_ajax.html?wd=" + quote(keyword) + f"&by=time&order=desc&page={page}" + items = self._parse_search_cards(self._request_html(url)) + return {"page": page, "limit": len(items), "total": len(items), "list": items}