feat(02-01): add LinkParser and ParsedLink for unified URL parsing

- Create ParsedLink dataclass with platform, content_type, content_id fields
- Implement LinkParser class with regex-based URL parsing
- Support Bilibili video/article URLs (with/without www)
- Support Zhihu answer/zhuanlan URLs
- Support b23.tv short link resolution with requests.head
- Return None for unrecognized URLs (no exceptions)
- Add 15 unit tests covering all supported URL types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 18:12:16 +08:00
parent 8a8959cebe
commit dd94f068e7
2 changed files with 412 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
"""Link Parser module
Provides unified URL parsing for various content platforms.
Supports Bilibili (video, article) and Zhihu (answer, zhuanlan) URLs.
"""
import re
from dataclasses import dataclass, field
from typing import Optional
import requests
from kb_pipeline.retry import retry_on_network_error
@dataclass
class ParsedLink:
"""Represents a parsed link with platform and content information
Attributes:
platform: Platform name (bilibili, zhihu)
content_type: Content type (video, article, answer, zhuanlan)
content_id: Unique content identifier
original_url: Original URL provided by user
resolved_url: Resolved URL after short link expansion (defaults to original_url)
"""
platform: str
content_type: str
content_id: str
original_url: str
resolved_url: str = field(default="")
def __post_init__(self):
"""Set resolved_url to original_url if not provided"""
if not self.resolved_url:
self.resolved_url = self.original_url
class LinkParser:
"""Unified URL parser for multiple platforms
Parses URLs from Bilibili and Zhihu, extracting platform, content type,
and content ID. Supports short link resolution for b23.tv URLs.
"""
# Bilibili patterns
BILIBILI_VIDEO_PATTERN = re.compile(
r'https?://(?:www\.)?bilibili\.com/video/(BV[a-zA-Z0-9]+)',
re.IGNORECASE
)
BILIBILI_ARTICLE_PATTERN = re.compile(
r'https?://(?:www\.)?bilibili\.com/(?:read|article)/(cv[a-zA-Z0-9]+)',
re.IGNORECASE
)
# Zhihu patterns
ZHIHU_ANSWER_PATTERN = re.compile(
r'https?://(?:www\.)?zhihu\.com/question/\d+/answer/(\d+)',
re.IGNORECASE
)
ZHIHU_ZHUANLAN_PATTERN = re.compile(
r'https?://zhuanlan\.zhihu\.com/p/(\d+)',
re.IGNORECASE
)
# Short link patterns
B23_TV_PATTERN = re.compile(
r'https?://(?:www\.)?b23\.tv/([a-zA-Z0-9]+)',
re.IGNORECASE
)
# Timeout for short link resolution
SHORT_LINK_TIMEOUT = 10
@retry_on_network_error
def _resolve_shortlink(self, url: str) -> Optional[str]:
"""Resolve short link to its final URL
Uses HTTP HEAD request with redirects to get the final URL.
Args:
url: Short link URL to resolve
Returns:
Final URL after redirects, or None if resolution failed
"""
try:
response = requests.head(
url,
allow_redirects=True,
timeout=self.SHORT_LINK_TIMEOUT
)
if response.status_code == 200:
return response.url
return None
except (requests.RequestException, OSError):
return None
def parse(self, url: str) -> Optional[ParsedLink]:
"""Parse a URL and extract platform, content type, and ID
Args:
url: URL to parse
Returns:
ParsedLink with extracted information, or None if URL is not recognized
"""
if not url or not isinstance(url, str):
return None
url = url.strip()
# Try b23.tv short link first
if self.B23_TV_PATTERN.match(url):
resolved_url = self._resolve_shortlink(url)
if resolved_url:
# Recursively parse the resolved URL
result = self._parse_standard_url(resolved_url)
if result:
result.original_url = url
result.resolved_url = resolved_url
return result
return None
return self._parse_standard_url(url)
def _parse_standard_url(self, url: str) -> Optional[ParsedLink]:
"""Parse a standard (non-short) URL
Args:
url: URL to parse
Returns:
ParsedLink with extracted information, or None if URL is not recognized
"""
# Try Bilibili video
match = self.BILIBILI_VIDEO_PATTERN.match(url)
if match:
return ParsedLink(
platform="bilibili",
content_type="video",
content_id=match.group(1),
original_url=url,
resolved_url=url
)
# Try Bilibili article
match = self.BILIBILI_ARTICLE_PATTERN.match(url)
if match:
return ParsedLink(
platform="bilibili",
content_type="article",
content_id=match.group(1),
original_url=url,
resolved_url=url
)
# Try Zhihu answer
match = self.ZHIHU_ANSWER_PATTERN.match(url)
if match:
return ParsedLink(
platform="zhihu",
content_type="answer",
content_id=match.group(1),
original_url=url,
resolved_url=url
)
# Try Zhihu zhuanlan
match = self.ZHIHU_ZHUANLAN_PATTERN.match(url)
if match:
return ParsedLink(
platform="zhihu",
content_type="zhuanlan",
content_id=match.group(1),
original_url=url,
resolved_url=url
)
# URL not recognized
return None
+231
View File
@@ -0,0 +1,231 @@
"""Tests for LinkParser module
Tests URL parsing for various platforms:
- Bilibili video URLs
- Bilibili article URLs
- Zhihu answer URLs
- Zhihu zhuanlan URLs
- Short link resolution (b23.tv)
- Unknown URL handling
"""
import pytest
from unittest.mock import patch, MagicMock
class TestLinkParserBilibiliVideo:
"""Tests for Bilibili video URL parsing"""
def test_parse_bilibili_video_url_returns_correct_parsed_link(self):
"""Test 1: 解析B站视频URL返回正确的 ParsedLink (platform=bilibili, type=video, id=BVxxx)"""
from kb_pipeline.link_parser import LinkParser, ParsedLink
parser = LinkParser()
result = parser.parse("https://www.bilibili.com/video/BV1GJ411x7Qw")
assert result is not None
assert result.platform == "bilibili"
assert result.content_type == "video"
assert result.content_id == "BV1GJ411x7Qw"
assert result.original_url == "https://www.bilibili.com/video/BV1GJ411x7Qw"
def test_parse_bilibili_video_url_without_www(self):
"""B站视频URL不带www也能正确解析"""
from kb_pipeline.link_parser import LinkParser
parser = LinkParser()
result = parser.parse("https://bilibili.com/video/BV1GJ411x7Qw")
assert result is not None
assert result.platform == "bilibili"
assert result.content_type == "video"
assert result.content_id == "BV1GJ411x7Qw"
def test_parse_bilibili_video_url_with_http(self):
"""B站视频URL使用http也能正确解析"""
from kb_pipeline.link_parser import LinkParser
parser = LinkParser()
result = parser.parse("http://www.bilibili.com/video/BV1GJ411x7Qw")
assert result is not None
assert result.platform == "bilibili"
assert result.content_type == "video"
class TestLinkParserBilibiliArticle:
"""Tests for Bilibili article URL parsing"""
def test_parse_bilibili_article_url_returns_correct_parsed_link(self):
"""Test 2: 解析B站专栏URL返回正确的 ParsedLink (platform=bilibili, type=article, id=cvxxx)"""
from kb_pipeline.link_parser import LinkParser
parser = LinkParser()
result = parser.parse("https://www.bilibili.com/read/cv12345678")
assert result is not None
assert result.platform == "bilibili"
assert result.content_type == "article"
assert result.content_id == "cv12345678"
def test_parse_bilibili_article_url_with_article_prefix(self):
"""B站专栏URL使用article前缀也能正确解析"""
from kb_pipeline.link_parser import LinkParser
parser = LinkParser()
result = parser.parse("https://www.bilibili.com/article/cv12345678")
assert result is not None
assert result.platform == "bilibili"
assert result.content_type == "article"
assert result.content_id == "cv12345678"
class TestLinkParserZhihuAnswer:
"""Tests for Zhihu answer URL parsing"""
def test_parse_zhihu_answer_url_returns_correct_parsed_link(self):
"""Test 3: 解析知乎问答URL返回正确的 ParsedLink (platform=zhihu, type=answer, id=xxx)"""
from kb_pipeline.link_parser import LinkParser
parser = LinkParser()
result = parser.parse("https://www.zhihu.com/question/12345678/answer/87654321")
assert result is not None
assert result.platform == "zhihu"
assert result.content_type == "answer"
assert result.content_id == "87654321"
def test_parse_zhihu_answer_url_without_www(self):
"""知乎问答URL不带www也能正确解析"""
from kb_pipeline.link_parser import LinkParser
parser = LinkParser()
result = parser.parse("https://zhihu.com/question/12345678/answer/87654321")
assert result is not None
assert result.platform == "zhihu"
assert result.content_type == "answer"
class TestLinkParserZhihuZhuanlan:
"""Tests for Zhihu zhuanlan URL parsing"""
def test_parse_zhihu_zhuanlan_url_returns_correct_parsed_link(self):
"""Test 4: 解析知乎专栏URL返回正确的 ParsedLink (platform=zhihu, type=zhuanlan, id=xxx)"""
from kb_pipeline.link_parser import LinkParser
parser = LinkParser()
result = parser.parse("https://zhuanlan.zhihu.com/p/12345678")
assert result is not None
assert result.platform == "zhihu"
assert result.content_type == "zhuanlan"
assert result.content_id == "12345678"
class TestLinkParserShortLink:
"""Tests for short link resolution"""
@patch("kb_pipeline.link_parser.requests.head")
def test_parse_b23tv_shortlink_resolves_to_real_url(self, mock_head):
"""Test 5: 解析b23.tv短链返回解析后的真实URL"""
from kb_pipeline.link_parser import LinkParser
# Mock the redirect response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.url = "https://www.bilibili.com/video/BV1GJ411x7Qw"
mock_head.return_value = mock_response
parser = LinkParser()
result = parser.parse("https://b23.tv/abc123")
assert result is not None
assert result.platform == "bilibili"
assert result.content_type == "video"
assert result.content_id == "BV1GJ411x7Qw"
assert result.resolved_url == "https://www.bilibili.com/video/BV1GJ411x7Qw"
@patch("kb_pipeline.link_parser.requests.head")
def test_shortlink_resolution_uses_allow_redirects(self, mock_head):
"""短链解析应该使用 allow_redirects=True"""
from kb_pipeline.link_parser import LinkParser
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.url = "https://www.bilibili.com/video/BV1GJ411x7Qw"
mock_head.return_value = mock_response
parser = LinkParser()
parser.parse("https://b23.tv/abc123")
mock_head.assert_called_once()
call_kwargs = mock_head.call_args[1]
assert call_kwargs.get("allow_redirects") is True
class TestLinkParserUnknownURL:
"""Tests for unknown URL handling"""
def test_parse_unknown_url_returns_none(self):
"""Test 6: 无法识别的URL返回None(不抛异常)"""
from kb_pipeline.link_parser import LinkParser
parser = LinkParser()
result = parser.parse("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
assert result is None
def test_parse_invalid_url_returns_none(self):
"""无效URL返回None"""
from kb_pipeline.link_parser import LinkParser
parser = LinkParser()
result = parser.parse("not a valid url")
assert result is None
def test_parse_empty_string_returns_none(self):
"""空字符串返回None"""
from kb_pipeline.link_parser import LinkParser
parser = LinkParser()
result = parser.parse("")
assert result is None
class TestParsedLink:
"""Tests for ParsedLink dataclass"""
def test_parsed_link_has_all_required_fields(self):
"""ParsedLink 应包含所有必需字段"""
from kb_pipeline.link_parser import ParsedLink
link = ParsedLink(
platform="bilibili",
content_type="video",
content_id="BV1GJ411x7Qw",
original_url="https://www.bilibili.com/video/BV1GJ411x7Qw",
resolved_url="https://www.bilibili.com/video/BV1GJ411x7Qw"
)
assert link.platform == "bilibili"
assert link.content_type == "video"
assert link.content_id == "BV1GJ411x7Qw"
assert link.original_url == "https://www.bilibili.com/video/BV1GJ411x7Qw"
assert link.resolved_url == "https://www.bilibili.com/video/BV1GJ411x7Qw"
def test_parsed_link_resolved_url_defaults_to_original(self):
"""ParsedLink 的 resolved_url 默认等于 original_url"""
from kb_pipeline.link_parser import ParsedLink
link = ParsedLink(
platform="bilibili",
content_type="video",
content_id="BV1GJ411x7Qw",
original_url="https://www.bilibili.com/video/BV1GJ411x7Qw"
)
assert link.resolved_url == link.original_url