feat(obsidian-kb): 实现短链接解析功能

- 新增 shortlink_resolver.py 模块
  * 检测 B站短链接(b23.tv)和其他短链接服务
  * 提供友好的用户引导信息
  * 支持 Playwright 浏览器自动化解析(可选)

- 集成到 handler.py
  * 短链接自动检测和提示
  * 智能区分短链接和完整 URL

- 更新 add_video.py
  * 支持短链接解析
  * 添加 --playwright 参数启用浏览器解析

- 新增 test_shortlink.py 测试脚本
  * 测试单个短链接检测
  * 测试多个短链接检测
  * 测试混合内容场景
  * 所有测试通过

- 更新文档
  * SKILL.md: 添加短链接说明
  * AUTO_TRIGGER.md: 添加短链接章节
  * obsidian-kb-improvements.md: 标记任务2为已完成

功能特点:
- 方案A(用户引导):已实现并作为默认行为
- 方案B(浏览器自动化):提供 PlaywrightResolver 类,可选使用
- 方案C(代理池):未实现(复杂度高,收益低)

验收标准达成:
✅ 短链接能够友好处理(显示引导信息)
✅ 用户不会感到困惑
✅ 支持 b23.tv 和其他短链接服务(检测)
✅ 集成到 handler.py
✅ 测试各种短链接场景
This commit is contained in:
OpenClaw Agent
2026-02-06 21:22:45 +08:00
parent 7a1ad6088a
commit fbcc3df2cf
7 changed files with 746 additions and 77 deletions
+66 -5
View File
@@ -176,12 +176,73 @@ handler.py 可以通过以下方式集成:
## 后续工作
### 短链接解析(P2 优先级)
### 短链接解析(P2 优先级) ✅ 已完成
当前短链接仅显示提示,下一步可实现:
- 使用浏览器自动化(Playwright)解析短链接
- 实现代理池和请求头伪装
- 缓存解析结果
已完成短链接解析功能(2026-02-06):
#### 已实现功能
1. **短链接检测模块** (`shortlink_resolver.py`)
- 检测 B站短链接(b23.tv)
- 支持其他短链接服务(bit.ly, t.cn, dwz.cn 等)
- 提供友好的用户引导信息
2. **用户引导(方案A)**
- 检测到短链接时显示清晰的操作指南
- 分别针对手机用户和桌面用户提供建议
- 支持单个短链接和多个短链接的不同提示格式
3. **浏览器自动化(方案B - 可选)**
- 提供了 PlaywrightResolver 类
- 需要额外安装 Playwright:
```bash
pip install playwright
playwright install chromium
```
- 可在 `add_video.py` 中使用 `--playwright` 参数启用
4. **集成到现有流程**
- `handler.py` 集成短链接检测
- `add_video.py` 支持短链接解析
- 短链接会优先显示帮助信息,不会自动处理
#### 使用示例
**handler.py 自动触发:**
```bash
python3 handler.py "Check this: https://b23.tv/oLBZZfK"
```
**add_video.py 手动添加:**
```bash
python3 add_video.py "https://b23.tv/oLBZZfK"
python3 add_video.py "https://b23.tv/oLBZZfK" --playwright # 使用浏览器解析
```
**shortlink_resolver.py 独立测试:**
```bash
python3 shortlink_resolver.py "Test link: https://b23.tv/abc"
python3 shortlink_resolver.py "https://b23.tv/abc" --playwright # 浏览器解析
```
#### 文件变更
```
skills/obsidian-kb/
├── SKILL.md [修改] 添加短链接说明
├── handler.py [修改] 集成短链接检测
├── add_video.py [修改] 支持短链接解析
├── shortlink_resolver.py [新增] 短链接解析模块
└── AUTO_TRIGGER.md [修改] 添加短链接章节
```
#### 验收标准
- [x] 短链接能够友好处理(显示引导信息)
- [x] 用户不会感到困惑
- [x] 支持 b23.tv 和其他短链接服务(检测)
- [x] 集成到 handler.py
- [x] 测试各种短链接场景(单个、多个、混合)
### YouTube 和知乎支持(P1 优先级)
+29 -1
View File
@@ -55,13 +55,17 @@ author: OpenClaw
### 短链接友好提示
检测到短链接时,显示引导信息:
**单个短链接:**
```
🔗 检测到短链接
⚠️ 由于反爬虫保护,短链接无法自动解析
📍 短链接: https://b23.tv/xxxxx
📱 手机用户:
1. 打开链接
1. 点击/打开链接
2. 在浏览器地址栏复制完整 URL
3. 发送给助手
@@ -72,6 +76,30 @@ author: OpenClaw
💡 提示:完整 URL 格式为:https://www.bilibili.com/video/BVxxxxx
```
**多个短链接:**
```
⚠️ 检测到短链接
[1] B站短链接: https://b23.tv/abc123
需要完整URL或BV号
[2] bit.ly: https://bit.ly/test
需要完整URL
💡 建议:
- 在浏览器打开链接后复制完整URL
- 或者直接发送BV号(仅限B站)
```
**技术说明:**
- B站短链接(b23.tv)有反爬虫保护,无法直接解析
- 本技能使用 `shortlink_resolver.py` 模块检测短链接
- 提供 Playwright 可选方案(需额外安装)
```bash
pip install playwright
playwright install chromium
```
## 命令列表
| 命令 | 描述 | 用法 |
Regular → Executable
+45 -11
View File
@@ -13,16 +13,22 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src
from sources.bilibili import BilibiliAdapter
from models import Content, ContentStatus
from storage import Storage
from shortlink_resolver import (
ShortLinkResolver,
PlaywrightResolver,
detect_and_format
)
def resolve_short_url(url: str) -> str:
def resolve_short_url(url: str, try_playwright: bool = False) -> str:
"""
解析短链接(如 b23.tv)到完整URL,或接受BV号
Args:
url: 可能是短链接的URL、完整URL或BV号
try_playwright: 是否尝试使用Playwright解析短链接
Returns:
str: 完整URL或BV号
str: 完整URL或BV号,如果失败返回None
"""
# 如果已经是完整URL,直接返回
if 'bilibili.com/video/' in url:
@@ -33,23 +39,42 @@ def resolve_short_url(url: str) -> str:
return url
# 如果是短链接,尝试解析
if 'b23.tv' in url:
resolver = ShortLinkResolver()
if resolver.is_bilibili_short_link(url):
print(f" ⚠️ 检测到短链接 {url}")
print(f" 💡 B站对短链接有反爬虫保护,请提供:")
print(f" 1. 完整URL: https://www.bilibili.com/video/BVxxxxx")
print(f" 2. 或BV号: BVxxxxx")
print(f"\n 提示: 可以在浏览器打开短链接,从地址栏复制完整URL")
# 尝试使用Playwright解析(如果启用)
if try_playwright:
print(f" 🔄 尝试使用浏览器解析...")
playwright_resolver = PlaywrightResolver(headless=True)
try:
resolved_url = playwright_resolver.resolve_bilibili_short(url)
if resolved_url:
print(f" ✅ 解析成功: {resolved_url}")
return resolved_url
else:
print(f" ❌ 浏览器解析失败")
except Exception as e:
print(f" ❌ Playwright不可用或解析失败: {e}")
# 显示用户引导
print("")
print(resolver.format_short_link_help(url))
print("")
return None
# 默认返回原URL
return url
def add_video_to_inbox(url: str) -> str:
def add_video_to_inbox(url: str, try_playwright: bool = False) -> str:
"""
添加B站视频到收件箱
Args:
url: B站视频URL
try_playwright: 是否尝试使用Playwright解析短链接
Returns:
str: Content ID
@@ -57,7 +82,7 @@ def add_video_to_inbox(url: str) -> str:
print(f"📥 正在处理视频: {url}")
# 解析短链接
resolved_url = resolve_short_url(url)
resolved_url = resolve_short_url(url, try_playwright=try_playwright)
if resolved_url is None:
return None
@@ -104,8 +129,17 @@ def add_video_to_inbox(url: str) -> str:
if __name__ == '__main__':
if len(sys.argv) < 2:
print("用法: python3 add_video.py <B站视频URL>")
print("示例: python3 add_video.py https://b23.tv/oLBZZfK")
print("示例:")
print(" python3 add_video.py https://www.bilibili.com/video/BV1xx411c7mD")
print(" python3 add_video.py BV1xx411c7mD")
print(" python3 add_video.py https://b23.tv/oLBZZfK")
print("\n选项:")
print(" --playwright 尝试使用浏览器解析短链接")
sys.exit(1)
url = sys.argv[1]
add_video_to_inbox(url)
try_playwright = '--playwright' in sys.argv
if try_playwright and url == '--playwright' and len(sys.argv) > 2:
url = sys.argv[2]
add_video_to_inbox(url, try_playwright=try_playwright)
+20 -16
View File
@@ -16,6 +16,13 @@ from urllib.parse import urlparse
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src'))
# Import short link resolver
from shortlink_resolver import (
ShortLinkResolver,
detect_and_format,
PlaywrightResolver
)
# Pattern definitions for auto-detection
PATTERNS = {
'bilibili_full': re.compile(r'https?://(?:www\.)?bilibili\.com/video/(BV\w+)'),
@@ -147,27 +154,17 @@ def format_preview(detected: List[Dict[str, str]]) -> str:
return "\n".join(lines)
def format_short_link_help() -> str:
def format_short_link_help(short_link: Optional[str] = None) -> str:
"""
Format help message for short links.
Args:
short_link: Optional short link URL to mention
Returns:
Help message
"""
return """🔗 检测到短链接
⚠️ 由于反爬虫保护,短链接无法自动解析
📱 手机用户:
1. 打开链接
2. 在浏览器地址栏复制完整 URL
3. 发送给助手
💻 桌面用户:
1. 发送 BV 号(如:BV1jcFqz7EFW)
2. 或使用完整 URL
💡 提示:完整 URL 格式为:https://www.bilibili.com/video/BVxxxxx"""
return ShortLinkResolver.format_short_link_help(short_link)
def get_action_prompt(detected: List[Dict[str, str]]) -> str:
@@ -289,12 +286,19 @@ def handle_message(text: str) -> Optional[str]:
Returns:
Response message, or None if no action needed
"""
# Detect links
# First, check for short links and provide help
has_short, short_msg = detect_and_format(text)
# Detect all links (including short ones)
detected = detect_links(text)
if not detected:
return None
# If we have short links, show help message first
if has_short:
return short_msg
# Check if this is a confirmation (y/yes)
text_lower = text.lower().strip()
if text_lower in ('y', 'yes', '是', '好的', 'ok'):
+355
View File
@@ -0,0 +1,355 @@
#!/usr/bin/env python3
"""
Short link resolver for obsidian-kb skill.
Provides utilities for detecting and resolving short links, particularly
Bilibili short links (b23.tv) which may be blocked by anti-scraping measures.
Supported short link services:
- Bilibili: b23.tv
- Generic URL shorteners (detection only)
"""
import re
from typing import Optional, Dict, List, Tuple
from urllib.parse import urlparse
class ShortLinkResolver:
"""Short link detection and resolution handler"""
# Pattern for Bilibili short links
BILIBILI_SHORT_PATTERN = re.compile(r'https?://b23\.tv/([a-zA-Z0-9]+)')
# Pattern for other short link services
OTHER_SHORT_PATTERNS = {
'bit.ly': re.compile(r'https?://bit\.ly/\w+'),
't.cn': re.compile(r'https?://t\.cn/\w+'),
'dwz.cn': re.compile(r'https?://dwz\.cn/\w+'),
'goo.gl': re.compile(r'https?://goo\.gl/\w+'),
'tinyurl.com': re.compile(r'https?://tinyurl\.com/\w+'),
}
@staticmethod
def detect_short_links(text: str) -> List[Dict[str, str]]:
"""
Detect short links in text.
Args:
text: Input text to search
Returns:
List of detected short links with metadata:
[
{
'url': 'https://b23.tv/xxxxx',
'platform': 'bilibili',
'is_short': True
},
...
]
"""
detected = []
# Check for Bilibili short links
for match in ShortLinkResolver.BILIBILI_SHORT_PATTERN.finditer(text):
detected.append({
'url': match.group(0),
'code': match.group(1),
'platform': 'bilibili',
'is_short': True
})
# Check for other short link services
for platform, pattern in ShortLinkResolver.OTHER_SHORT_PATTERNS.items():
for match in pattern.finditer(text):
detected.append({
'url': match.group(0),
'platform': platform,
'is_short': True
})
return detected
@staticmethod
def is_bilibili_short_link(url: str) -> bool:
"""
Check if URL is a Bilibili short link.
Args:
url: URL to check
Returns:
True if URL is a b23.tv short link
"""
return bool(ShortLinkResolver.BILIBILI_SHORT_PATTERN.match(url))
@staticmethod
def format_short_link_help(short_link: Optional[str] = None) -> str:
"""
Format help message for short links.
Args:
short_link: Optional short link URL to mention
Returns:
Help message
"""
lines = [
"🔗 检测到短链接",
"",
"⚠️ 由于反爬虫保护,短链接无法自动解析",
""
]
if short_link:
lines.append(f"📍 短链接: {short_link}")
lines.append("")
lines.extend([
"📱 手机用户:",
" 1. 点击/打开链接",
" 2. 在浏览器地址栏复制完整 URL",
" 3. 发送给助手",
"",
"💻 桌面用户:",
" 1. 发送 BV 号(如:BV1jcFqz7EFW)",
" 2. 或使用完整 URL",
"",
"💡 提示:完整 URL 格式为:https://www.bilibili.com/video/BVxxxxx"
])
return "\n".join(lines)
@staticmethod
def format_short_link_warning(short_links: List[Dict[str, str]]) -> str:
"""
Format warning message for multiple short links.
Args:
short_links: List of short link metadata
Returns:
Warning message
"""
if not short_links:
return ""
lines = [
"⚠️ 检测到短链接",
""
]
for i, link in enumerate(short_links, 1):
platform = link.get('platform', 'unknown')
url = link['url']
if platform == 'bilibili':
lines.append(f" [{i}] B站短链接: {url}")
lines.append(" 需要完整URL或BV号")
else:
lines.append(f" [{i}] {platform}: {url}")
lines.append(" 需要完整URL")
lines.append("")
lines.extend([
"💡 建议:",
" - 在浏览器打开链接后复制完整URL",
" - 或者直接发送BV号(仅限B站)"
])
return "\n".join(lines)
class PlaywrightResolver:
"""
Browser-based short link resolver using Playwright.
This is an optional implementation for environments where Playwright
is available. It can bypass anti-scraping measures by simulating
a real browser.
Requirements:
pip install playwright
playwright install chromium
"""
def __init__(self, headless: bool = True, timeout: int = 10000):
"""
Initialize Playwright resolver.
Args:
headless: Whether to run browser in headless mode
timeout: Page navigation timeout in milliseconds
"""
self.headless = headless
self.timeout = timeout
self._playwright = None
def _check_playwright_available(self) -> bool:
"""Check if Playwright is installed and available"""
try:
from playwright.sync_api import sync_playwright
return True
except ImportError:
return False
def resolve(self, short_url: str) -> Optional[str]:
"""
Resolve short link using Playwright browser automation.
Args:
short_url: Short URL to resolve
Returns:
Final URL after redirects, or None if resolution fails
"""
if not self._check_playwright_available():
raise ImportError(
"Playwright not installed. Install with: pip install playwright && playwright install chromium"
)
from playwright.sync_api import sync_playwright
try:
with sync_playwright() as p:
# Launch browser
browser = p.chromium.launch(headless=self.headless)
context = browser.new_context(
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
viewport={'width': 1920, 'height': 1080}
)
page = context.new_page()
# Navigate to short URL
page.goto(short_url, timeout=self.timeout, wait_until='networkidle')
# Get final URL
final_url = page.url
# Close browser
browser.close()
return final_url
except Exception as e:
print(f"Playwright resolution failed: {e}")
return None
def resolve_bilibili_short(self, short_url: str) -> Optional[str]:
"""
Resolve Bilibili short link and extract BV ID.
Args:
short_url: Bilibili short URL (e.g., https://b23.tv/xxxxx)
Returns:
Full Bilibili URL with BV ID, or None if resolution fails
"""
final_url = self.resolve(short_url)
if not final_url:
return None
# Check if final URL contains BV ID
if 'bilibili.com/video/' in final_url:
return final_url
# Extract BV ID if present in URL
bv_pattern = re.compile(r'(BV[a-zA-Z0-9]{10})')
match = bv_pattern.search(final_url)
if match:
return f"https://www.bilibili.com/video/{match.group(1)}"
return None
def get_resolver() -> ShortLinkResolver:
"""
Get the default resolver instance.
Returns:
ShortLinkResolver instance
"""
return ShortLinkResolver()
def detect_and_format(text: str) -> Tuple[bool, Optional[str]]:
"""
Detect short links in text and format appropriate message.
Args:
text: Input text to search
Returns:
Tuple of (has_short_links, formatted_message)
- has_short_links: True if short links detected
- formatted_message: Help/warning message if short links found, None otherwise
"""
resolver = ShortLinkResolver()
short_links = resolver.detect_short_links(text)
if not short_links:
return False, None
# Determine appropriate message based on number and type of links
if len(short_links) == 1:
link = short_links[0]
if link['platform'] == 'bilibili':
return True, resolver.format_short_link_help(link['url'])
else:
return True, resolver.format_short_link_warning([link])
else:
return True, resolver.format_short_link_warning(short_links)
# CLI interface for testing
def main():
"""CLI for testing the short link resolver."""
import sys
if len(sys.argv) < 2:
print("Usage: python3 shortlink_resolver.py 'text to test'")
print("\nExamples:")
print(' python3 shortlink_resolver.py "Check this: https://b23.tv/oLBZZfK"')
print(' python3 shortlink_resolver.py "https://bit.ly/xxxxx"')
print('\nOptions:')
print(' --playwright Try to resolve short links using Playwright')
sys.exit(1)
text = ' '.join(sys.argv[1:])
# Check if we should try Playwright
use_playwright = '--playwright' in sys.argv
if use_playwright:
text = text.replace('--playwright', '').strip()
resolver = PlaywrightResolver()
# Find short links
short_links = ShortLinkResolver.detect_short_links(text)
for link in short_links:
url = link['url']
print(f"Resolving: {url}")
if link['platform'] == 'bilibili':
resolved = resolver.resolve_bilibili_short(url)
else:
resolved = resolver.resolve(url)
if resolved:
print(f" → {resolved}")
else:
print(f" ✗ Resolution failed")
else:
# Just detect and show help
has_links, message = detect_and_format(text)
if has_links:
print(message)
else:
print("No short links detected")
if __name__ == '__main__':
main()
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""
Test script for short link resolver functionality.
"""
import sys
import os
# Add path for imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from shortlink_resolver import ShortLinkResolver, detect_and_format
def test_single_bilibili_short():
"""Test single Bilibili short link detection."""
print("Test 1: Single Bilibili short link")
print("=" * 50)
text = "Check this video: https://b23.tv/oLBZZfK"
has_links, message = detect_and_format(text)
print(f"Input: {text}")
print(f"Has short links: {has_links}")
if message:
print(f"Message:\n{message}")
print()
assert has_links, "Should detect short link"
assert message, "Should have help message"
print("✅ Test 1 passed\n")
def test_multiple_short_links():
"""Test multiple short links detection."""
print("Test 2: Multiple short links")
print("=" * 50)
text = "Check these: https://b23.tv/abc123 and https://bit.ly/test"
has_links, message = detect_and_format(text)
print(f"Input: {text}")
print(f"Has short links: {has_links}")
if message:
print(f"Message:\n{message}")
print()
assert has_links, "Should detect short links"
assert message, "Should have warning message"
assert "[1]" in message, "Should show first link"
assert "[2]" in message, "Should show second link"
print("✅ Test 2 passed\n")
def test_no_short_links():
"""Test text with no short links."""
print("Test 3: No short links")
print("=" * 50)
text = "Here is a full URL: https://www.bilibili.com/video/BV1xx411c7mD"
has_links, message = detect_and_format(text)
print(f"Input: {text}")
print(f"Has short links: {has_links}")
if message:
print(f"Message: {message}")
else:
print("Message: None")
print()
assert not has_links, "Should not detect short links"
assert not message, "Should not have help message"
print("✅ Test 3 passed\n")
def test_bv_code():
"""Test BV code (should not be treated as short link)."""
print("Test 4: BV code")
print("=" * 50)
text = "BV1jcFqz7EFW"
has_links, message = detect_and_format(text)
print(f"Input: {text}")
print(f"Has short links: {has_links}")
if message:
print(f"Message: {message}")
else:
print("Message: None")
print()
assert not has_links, "BV code should not be treated as short link"
assert not message, "Should not have help message"
print("✅ Test 4 passed\n")
def test_mixed_content():
"""Test mixed content with short and full links."""
print("Test 5: Mixed content")
print("=" * 50)
text = """
Watch these videos:
https://www.bilibili.com/video/BV1xx411c7mD
https://b23.tv/short123
BV2yy522c8nE
"""
has_links, message = detect_and_format(text)
print(f"Input:{text}")
print(f"Has short links: {has_links}")
if message:
print(f"Message:\n{message}")
print()
assert has_links, "Should detect short link"
assert message, "Should have help message"
print("✅ Test 5 passed\n")
def test_resolver_direct():
"""Test ShortLinkResolver directly."""
print("Test 6: Direct resolver usage")
print("=" * 50)
resolver = ShortLinkResolver()
# Test detection
short_links = resolver.detect_short_links("Check https://b23.tv/test123")
print(f"Detected: {short_links}")
assert len(short_links) == 1, "Should detect one short link"
assert short_links[0]['platform'] == 'bilibili', "Should be Bilibili"
assert short_links[0]['is_short'] == True, "Should be short link"
# Test format_short_link_help
help_msg = resolver.format_short_link_help("https://b23.tv/test123")
print(f"Help message:\n{help_msg}")
assert "短链接" in help_msg, "Should mention short link"
assert "BV" in help_msg, "Should mention BV code"
# Test format_short_link_warning
warning_msg = resolver.format_short_link_warning(short_links)
print(f"Warning message:\n{warning_msg}")
assert "[1]" in warning_msg, "Should show numbered list"
print()
print("✅ Test 6 passed\n")
def run_all_tests():
"""Run all tests."""
print("\n" + "=" * 50)
print("Running Short Link Resolver Tests")
print("=" * 50 + "\n")
try:
test_single_bilibili_short()
test_multiple_short_links()
test_no_short_links()
test_bv_code()
test_mixed_content()
test_resolver_direct()
print("\n" + "=" * 50)
print("✅ All tests passed!")
print("=" * 50 + "\n")
return 0
except AssertionError as e:
print(f"\n❌ Test failed: {e}\n")
return 1
except Exception as e:
print(f"\n❌ Unexpected error: {e}\n")
import traceback
traceback.print_exc()
return 1
if __name__ == '__main__':
sys.exit(run_all_tests())