
Crawl4ai Skill
- 815 installs
- 15 repo stars
- Updated March 11, 2026
- lancelin111/crawl4ai-skill
crawl4ai-skill is a web crawling agent skill that fetches clean, LLM-optimized markdown from sites and DuckDuckGo search results without requiring an API key.
About
crawl4ai-skill version 1.1.0 (MIT-0) wraps the crawl4ai CLI for web crawling, scraping, DuckDuckGo search, site traversal, and dynamic page extraction with token-optimized markdown output. The skill requires the crawl4ai binary and is published on PyPI as crawl4ai-skill. Developers reach for crawl4ai-skill when agents need research-grade page text, search results, or spider output without provisioning search API keys. Tags emphasize LLM token optimization, free operation, and bilingual discovery for crawler, scraper, and spider workflows inside coding-agent sessions.
- DuckDuckGo search with zero API key required
- Full site crawling with sitemap detection
- Smart web scraping for single and dynamic JavaScript pages
- LLM-optimized Markdown output that saves ~80% tokens
- Runs as local CLI and agent-compatible skill
Crawl4ai Skill by the numbers
- 815 all-time installs (skills.sh)
- +20 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #330 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/lancelin111/crawl4ai-skill --skill crawl4ai-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 815 |
|---|---|
| repo stars | ★ 15 |
| Last updated | March 11, 2026 |
| Repository | lancelin111/crawl4ai-skill ↗ |
How do agents crawl web pages into LLM markdown?
Fetch clean, LLM-optimized web content and search results without needing an API key.
Who is it for?
Developers building agent research, RAG ingestion, or scraping workflows who want keyless DuckDuckGo search and clean markdown output.
Skip if: Large-scale production crawlers needing authenticated APIs, strict robots compliance audits, or non-LLM binary asset pipelines.
When should I use this skill?
The user needs web scraping, DuckDuckGo search, site crawling, or LLM-ready markdown from pages without API keys.
What you get
LLM-optimized markdown extracts, search result pages, and crawled site content from crawl4ai CLI runs.
- llm-optimized markdown
- search result extracts
- crawled page corpus
By the numbers
- Version 1.1.0
- MIT-0 license
- PyPI package: crawl4ai-skill
Files
Crawl4AI Skill - Web Crawler & Scraper
Web Crawling 网页爬虫 | Web Scraping 网页爬取 | LLM 优化输出
智能网页爬虫和爬取工具,支持搜索、全站爬取、动态页面抓取。Free web crawler and scraper with LLM-optimized Markdown output.
核心功能 | Core Features
- 🔍 Web Search 网页搜索 - DuckDuckGo search, 免 API key
- 🕷️ Web Crawling 网页爬虫 - Site crawler, spider, sitemap 识别
- 📝 Web Scraping 网页抓取 - Smart scraper, data extraction
- 📄 LLM-Optimized Output - Fit Markdown, 省 Token 80%
- ⚡ Dynamic Page Scraping - JavaScript 渲染页面爬取
---
快速开始 | Quick Start
安装 | Installation
pip install crawl4ai-skillWeb Search | 网页搜索
# Search the web with DuckDuckGo
crawl4ai-skill search "python web scraping"Web Scraping | 单页爬取
# Scrape a single web page
crawl4ai-skill crawl https://example.comWeb Crawling | 全站爬虫
# Crawl entire website / spider
crawl4ai-skill crawl-site https://docs.python.org --max-pages 50---
使用场景 | Use Cases
场景 1:Web Crawler for Documentation | 文档站爬虫
# Crawl documentation site with spider
crawl4ai-skill crawl-site https://docs.fastapi.com --max-pages 100爬虫效果 | Crawler Output:
- ❌ 移除:导航栏、侧边栏、广告
- ✅ 保留:标题、正文、代码块
- 📊 Token:50,000 → 10,000(-80%)
场景 2:Search + Scrape | 搜索+爬取
# Search and scrape top results
crawl4ai-skill search-and-crawl "Vue 3 best practices" --crawl-top 3场景 3:Dynamic Page Scraping | 动态页面抓取
JavaScript 渲染的页面爬取(雪球、知乎等):
# Scrape JavaScript-heavy pages
crawl4ai-skill crawl https://xueqiu.com/S/BIDU --wait-until networkidle --delay 2---
命令参考 | Commands
| 命令 Command | 说明 Description |
|---|---|
search <query> | Web search 网页搜索 |
crawl <url> | Web scraping 单页爬取 |
crawl-site <url> | Web crawling 全站爬虫 |
search-and-crawl <query> | Search + scrape 搜索并爬取 |
常用参数 | Common Options
# Web Search 搜索
--num-results 10 # Number of results
# Web Scraping 爬取
--format fit_markdown # Output format
--output result.md # Output file
--wait-until networkidle # Wait strategy for dynamic pages
--delay 2 # Additional wait time (seconds)
--wait-for ".selector" # Wait for specific element
# Web Crawling 爬虫
--max-pages 100 # Max pages to crawl
--max-depth 3 # Max crawl depth---
输出格式 | Output Formats
fit_markdown(推荐 Recommended)
智能提取,节省 80% Token。Smart extraction, save 80% tokens.
crawl4ai-skill crawl https://example.com --format fit_markdownraw_markdown
保留完整结构。Preserve full structure.
crawl4ai-skill crawl https://example.com --format raw_markdown---
为什么选择这个爬虫?| Why This Crawler?
✅ 免费爬虫 Free Crawler - 无需 API key,开箱即用 ✅ 智能爬取 Smart Scraper - 自动去噪,提取核心内容 ✅ 全站爬虫 Site Crawler - 支持 sitemap,递归爬取 ✅ 动态爬取 Dynamic Scraping - JavaScript 渲染页面支持 ✅ 搜索集成 Search Integration - DuckDuckGo 搜索内置
---
链接 | Links
name: Security Scan
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
bandit:
name: Bandit Security Scan
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install bandit
run: |
pip install bandit[toml]
- name: Run Bandit
run: |
bandit -r src/ -f json -o bandit-report.json || true
bandit -r src/ -ll -f screen
- name: Upload Bandit report
uses: actions/upload-artifact@v4
if: always()
with:
name: bandit-security-report
path: bandit-report.json
retention-days: 30
dependency-check:
name: Dependency Security Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install pip-audit
run: pip install pip-audit
- name: Run pip-audit
run: pip-audit -r requirements.txt || true
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
*.egg-info/
dist/
build/
# Testing
.pytest_cache/
.coverage
htmlcov/
# IDE
.vscode/
.idea/
*.swp
*.swo
# Output
crawl_output/
search_crawl_output/
*.md
!README.md
!SKILL.md
!CONTRIBUTING.md
!CHANGELOG.md
!SECURITY.md
# Login sessions and browser data (sensitive)
sessions/
browser_data/
*.json
!package.json
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.2.0] - 2026-03-10
Added
- 🔐 登录功能 - 支持热门网站登录,保存 Session 后可爬取需要登录的页面
- Twitter/X 登录(Cookie 导入、用户名密码)
- 小红书登录(Cookie 导入、扫码)
- 🛡️ 反检测 - Playwright Stealth 配置
- User-Agent 轮换池
- 隐藏自动化特征
- 📦 Session 管理
- 统一管理所有平台的登录状态
- JSON 格式存储 Cookies 和 Storage State
- 🖥️ 新增 CLI 命令
login- 登录指定平台session-status- 查看所有平台登录状态session-clear- 清除保存的 Sessioncrawl-with-login- 使用已保存 Session 爬取页面
Technical
- 新增
src/browser/模块(Stealth 配置) - 新增
src/login/模块(登录基类、平台实现、Session 管理) - 新增
playwright-stealth和aiohttp依赖 - 新增 25+ 个登录模块单元测试
[0.1.1] - 2026-03-10
Added
- 🛡️ URL 验证功能(
validate_url函数和InvalidURLError异常) - 🔄 HTTP 错误处理(
HTTPError异常,包含状态码) - 📁 Sitemap Index 支持(嵌套 sitemap 递归解析)
- ⚠️ 限流错误处理(
RateLimitError异常) - 🛑 优雅中断处理(Ctrl+C 信号处理,保存已完成结果)
- ✅ 参数验证(
crawl-top不能超过num-results) - 📝 空结果处理(搜索无结果时的友好提示)
- 🔒 文件权限错误处理
Changed
- ⏱️ 优化请求延迟策略:从"每 5 个请求延迟 1 秒"改为"每个请求随机延迟 0.1~0.3 秒"
- 参考 crawl4ai 官方默认配置(mean_delay=0.1, max_range=0.3)
- 更快:平均延迟从 0.2 秒/请求 优化为更平滑的体验
- 更自然:随机延迟模拟人类行为,降低被检测风险
Fixed
- 修复 sitemap/txt 获取时的 HTTP 错误处理
- 修复空 sitemap/txt 文件的处理
- 改进 CLI 错误提示信息
Technical
- 新增 10 个单元测试(URL 验证、错误类)
- 测试总数从 45 增加到 57 个
[0.1.0] - 2026-03-10
Added
- 🔍 DuckDuckGo 搜索功能(免 API key)
- 🕷️ 单页爬取功能
- 🌐 智能全站爬取(支持 sitemap、llms-full.txt、递归策略)
- 📝 多种 Markdown 输出格式(fit_markdown、markdown_with_citations、raw_markdown)
- 🔗 搜索并爬取组合功能
- ⚙️ CLI 命令行工具
- 📖 完整文档和示例
Technical
- 基于 crawl4ai 0.8.0
- 兼容 ddgs(原 duckduckgo-search)搜索库
- 支持 Python 3.9+
- 45 个单元测试
Skills.sh 支持说明
安装方式
方式 1: 通过 skills.sh 生态(通用 Agent)
npx skills add lancelin111/crawl4ai-skill@crawl4ai-skill方式 2: 通过 ClawHub(OpenClaw 专用)
clawhub install crawl4ai-skill方式 3: 通过 PyPI(Python 包)
pip install crawl4ai-skill---
目录结构
crawl4ai-skill/
├── SKILL.md # 根目录(ClawHub 使用)
├── skills/ # skills.sh 生态
│ └── crawl4ai-skill/
│ └── SKILL.md # 技能定义文件
├── package.json # npm 包配置
├── pyproject.toml # Python 包配置
└── src/ # Python 源码---
生态对比
| 生态 | 安装命令 | 技能格式 | 用户群 |
|---|---|---|---|
| ClawHub | clawhub install | SKILL.md(根目录) | OpenClaw |
| skills.sh | npx skills add | skills/*/SKILL.md | Claude/Copilot/通用 |
| PyPI | pip install | Python 包 | Python 开发者 |
---
验证
验证 GitHub 结构
curl -s https://raw.githubusercontent.com/lancelin111/crawl4ai-skill/main/skills/crawl4ai-skill/SKILL.md | head -10验证 package.json
curl -s https://raw.githubusercontent.com/lancelin111/crawl4ai-skill/main/package.json | jq .---
推广策略
ClawHub(已完成 ✅)
- 发布版本:v1.0.9
- 安全评级:Benign
- 当前安装:0
skills.sh(新增 ✅)
- GitHub 仓库:公开
- 技能路径:skills/crawl4ai-skill/SKILL.md
- 安装命令:
npx skills add lancelin111/crawl4ai-skill@crawl4ai-skill
下一步
- [ ] 在 Vercel 论坛/Discord 发布
- [ ] 在 skills.sh 社区推广
- [ ] 添加到 awesome-agent-skills 列表
#!/bin/bash
# 深度爬取示例
# 全站爬取
echo "=== 深度爬取 example.com ==="
crawl4ai-skill crawl-site https://example.com \
--max-depth 2 \
--max-pages 10 \
--output-dir ./example_crawl
echo ""
echo "=== 查看爬取结果 ==="
ls -la ./example_crawl/
echo ""
echo "=== 查看统计信息 ==="
cat ./example_crawl/stats.json
echo ""
echo "完成!"
#!/bin/bash
# 搜索并爬取示例
# 搜索并爬取
echo "=== 搜索 'python web scraping' ==="
crawl4ai-skill search "python web scraping" --num-results 5
echo ""
echo "=== 搜索并爬取前 3 个结果 ==="
crawl4ai-skill search-and-crawl "python web scraping tutorials" \
--num-results 5 \
--crawl-top 3 \
--output-dir ./search_results
echo ""
echo "=== 查看结果 ==="
ls -la ./search_results/
echo ""
echo "完成!"
#!/bin/bash
# 简单爬取示例
# 爬取单个页面
echo "=== 爬取 example.com ==="
crawl4ai-skill crawl https://example.com
echo ""
echo "=== 爬取并保存到文件 ==="
crawl4ai-skill crawl https://example.com -o example.md
echo ""
echo "=== 使用 markdown_with_citations 格式 ==="
crawl4ai-skill crawl https://example.com -f markdown_with_citations
echo ""
echo "完成!"
MIT License
Copyright (c) 2026 Lancelin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
"name": "crawl4ai-skill",
"version": "0.3.0",
"description": "Smart web crawling and search skill for AI agents. Provides DuckDuckGo search and intelligent web scraping with LLM-optimized Markdown output.",
"keywords": [
"agent-skill",
"ai-agent",
"skills.sh",
"openclaw",
"web-scraping",
"web-crawling",
"llm",
"markdown",
"search",
"crawl4ai"
],
"repository": {
"type": "git",
"url": "https://github.com/lancelin111/crawl4ai-skill.git"
},
"homepage": "https://github.com/lancelin111/crawl4ai-skill",
"bugs": {
"url": "https://github.com/lancelin111/crawl4ai-skill/issues"
},
"author": "Lancelin",
"license": "MIT",
"files": [
"skills"
],
"scripts": {
"postinstall": "echo '✅ crawl4ai-skill installed. For OpenClaw: clawhub install crawl4ai-skill'"
}
}
[project]
name = "crawl4ai-skill"
version = "0.3.0"
description = "Smart web crawling and search for OpenClaw"
authors = [{name = "Lancelin"}]
readme = "README.md"
requires-python = ">=3.9"
license = {text = "MIT"}
keywords = ["crawl4ai", "openclaw", "web-scraping", "search"]
dependencies = [
"crawl4ai>=0.8.0",
"ddgs>=8.0.0",
"click>=8.1.0",
"pydantic>=2.0.0",
"aiohttp>=3.9.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=4.1.0",
"black>=24.0.0",
"ruff>=0.1.0",
]
[project.scripts]
crawl4ai-skill = "src.cli:cli"
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["."]
include = ["src*"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.black]
line-length = 88
target-version = ['py39']
[tool.ruff]
line-length = 88
target-version = "py39"
Crawl4AI Skill
<p align="center"> <strong>智能搜索与爬取工具 | LLM 优化输出</strong> </p>
<p align="center"> <a href="https://github.com/lancelin111/crawl4ai-skill/actions/workflows/security.yml"> <img src="https://github.com/lancelin111/crawl4ai-skill/actions/workflows/security.yml/badge.svg" alt="Security Scan"> </a> <a href="https://pypi.org/project/crawl4ai-skill/"> <img src="https://img.shields.io/pypi/v/crawl4ai-skill" alt="PyPI"> </a> <a href="https://pypi.org/project/crawl4ai-skill/"> <img src="https://img.shields.io/pypi/pyversions/crawl4ai-skill" alt="Python Version"> </a> <a href="https://github.com/lancelin111/crawl4ai-skill/blob/main/LICENSE"> <img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License"> </a> </p>
<p align="center"> <a href="#安装">安装</a> • <a href="#快速开始">快速开始</a> • <a href="#命令参考">命令参考</a> • <a href="#致谢">致谢</a> </p>
---
缘起
在使用 AI 助手处理信息时,我经常需要爬取网页内容。尝试了很多方案后,遇到了 crawl4ai —— 一个专为 LLM 设计的爬虫引擎,它的 Fit Markdown 输出简直是为 AI 量身定做的,去除了所有冗余内容,只保留核心信息。
这个项目就是这些探索的成果。希望能帮助到有同样需求的朋友。
特性
- 🔍 DuckDuckGo 搜索 - 免 API key,快速搜索
- 🕷️ 智能爬取 - 自动识别 sitemap、递归爬取
- 📝 LLM 优化输出 - Fit Markdown,节省 token
- ⚡ 动态页面支持 - 支持 JavaScript 渲染页面
安装
方式 1:PyPI(推荐)
pip install crawl4ai-skill方式 2:skills.sh 生态(通用 Agent)
适用于 Claude、Copilot、通用 AI Agent:
npx skills add lancelin111/crawl4ai-skill@crawl4ai-skill方式 3:ClawHub(OpenClaw 专用)
clawhub install crawl4ai-skillPyPI 包已通过:
- ✅ PyPI 官方验证(Verified by PyPI)
- ✅ 自动化安全扫描(Bandit + pip-audit)
- ✅ 依赖项审查(所有依赖均为知名开源项目)
从源码安装(开发者/审计)
git clone https://github.com/lancelin111/crawl4ai-skill.git
cd crawl4ai-skill
# 可选:使用 bandit 审计代码
pip install bandit
bandit -r src/
# 安装
pip install -e .快速开始
搜索
crawl4ai-skill search "python web scraping"爬取网页
crawl4ai-skill crawl https://example.com -o page.md爬取整站
crawl4ai-skill crawl-site https://docs.example.com --max-pages 50搜索并爬取
crawl4ai-skill search-and-crawl "AI tutorials" --crawl-top 3动态页面爬取
对于 JavaScript 渲染的动态页面(如雪球、知乎等),使用 --wait-until 和 --delay 参数:
# 等待网络空闲 + 额外等待 2 秒
crawl4ai-skill crawl https://xueqiu.com/S/BIDU --wait-until networkidle --delay 2
# 等待特定元素出现
crawl4ai-skill crawl https://example.com --wait-for ".content-loaded"| 参数 | 说明 |
|---|---|
--wait-until | 等待策略:domcontentloaded(默认), networkidle(推荐动态页面), load, commit |
--delay | 返回前额外等待时间(秒) |
--wait-for | 等待特定 CSS 选择器元素出现 |
命令参考
| 命令 | 说明 |
|---|---|
search <query> | 搜索网页 |
crawl <url> | 爬取单页 |
crawl-site <url> | 爬取全站 |
search-and-crawl <query> | 搜索并爬取 |
输出格式
| 格式 | 说明 |
|---|---|
fit_markdown | 优化后的 Markdown,去除冗余(推荐) |
markdown_with_citations | 带引用列表,便于溯源 |
raw_markdown | 原始 Markdown |
致谢
这个项目的诞生,离不开以下优秀的开源项目:
crawl4ai
一个真正为 LLM 设计的爬虫引擎。
当我第一次看到 crawl4ai 的 Fit Markdown 输出时,我被震撼了。它不是简单地把 HTML 转成 Markdown,而是智能地提取核心内容,去除导航、广告、侧边栏等噪音。这正是 AI 需要的输入格式 —— 干净、精炼、直击要点。
crawl4ai 的 PruningContentFilter 和 DefaultMarkdownGenerator 是本项目 Markdown 生成的核心。感谢 @unclecode 创造了这个强大的工具。
duckduckgo-search
免 API key 的搜索能力来自这个项目。简单、可靠、无需注册。
---
如果这个项目对你有帮助,请给上面这些项目一个 Star
它们才是真正的英雄。
License
MIT License
作者
---
<p align="center"> <em>Built with open source</em> </p>
crawl4ai>=0.8.0
ddgs>=8.0.0
playwright>=1.40.0
click>=8.1.0
pydantic>=2.0.0
defusedxml>=0.7.1
cryptography>=41.0.0
pytest>=8.0.0
pytest-asyncio>=0.23.0
pytest-cov>=4.1.0
black>=24.0.0
ruff>=0.1.0
Security Policy
Supported Versions
| Version | Supported |
|---|---|
| 0.2.x | :white_check_mark: |
| < 0.2 | :x: |
Security Measures
Data Storage
- ✅ All session data is stored locally in
~/.crawl4ai-skill/ - ✅ Session cookies are encrypted with AES-128-CBC
- ✅ Encryption key is derived from machine identifier (MAC address + hostname)
- ✅ File permissions are set to 600 (user read/write only)
- ✅ Cannot be decrypted on other machines
No Data Transmission
- ✅ Zero network transmission of credentials
- ✅ All data processing happens locally
- ✅ No telemetry, no analytics, no tracking
- ✅ No external API calls except target websites
Dependency Security
All dependencies are well-known, actively maintained open-source projects:
| Package | License | Maintainer | Description |
|---|---|---|---|
playwright | Apache 2.0 | Microsoft | Browser automation |
crawl4ai | Apache 2.0 | @unclecode | Web scraping for LLMs |
duckduckgo-search | MIT | @deedy5 | Search API |
playwright-stealth | MIT | @AtuboDad | Anti-detection |
cryptography | Apache 2.0/BSD | PyCA | AES encryption |
Automated Security Scanning
- ✅ Bandit security scan on every commit
- ✅ pip-audit dependency vulnerability check
- ✅ GitHub Dependabot enabled
- ✅ PyPI package security scan
Verification
Check if encryption is working:
crawl4ai-skill session-status
# Expected output:
# Platform: twitter
# Status: Logged in
# Encrypted: ✅ (AES-128-CBC)
# Last used: 2026-03-10 12:34:56Audit the code yourself:
# Clone and audit
git clone https://github.com/lancelin111/crawl4ai-skill.git
cd crawl4ai-skill
# Install audit tools
pip install bandit pip-audit
# Run security scans
bandit -r src/
pip-audit -r requirements.txt
# Review encryption implementation
cat src/crawl4ai_skill/login/session_manager.pyVerify no network transmission:
# Monitor network traffic while using the tool
# (example using tcpdump on macOS/Linux)
sudo tcpdump -i any -nn host pypi.org or host github.com
# Then run the tool
crawl4ai-skill login twitter --cookies "auth_token=test; ct0=test"
# You should see NO traffic to unknown servers
# Only requests to target websites (x.com, xiaohongshu.com)Credential Input Security
Recommended Methods (Secure → Less Secure)
| Method | Security | Shell History | Visibility |
|---|---|---|---|
| Environment Variable | ⭐⭐⭐ | ✅ Not recorded | ✅ Not visible |
| Interactive Input | ⭐⭐⭐ | ✅ Not recorded | ✅ Not visible |
| File Read | ⭐⭐ | ✅ Not recorded | ⚠️ File must be chmod 600 |
| Command Line | ⭐ | ❌ Recorded in history | ❌ Visible in ps |
Best Practice:
# Use environment variable
export TWITTER_COOKIES="auth_token=xxx; ct0=yyy"
crawl4ai-skill login twitter
# Or interactive mode
crawl4ai-skill login twitter --interactive
# (will prompt for input without displaying it)What We Do NOT Collect
- ❌ No cookies or credentials transmitted to external servers
- ❌ No telemetry or usage analytics
- ❌ No user tracking or fingerprinting
- ❌ No crash reports sent to third parties
- ❌ No email or personal information stored
Reporting a Vulnerability
Please report security vulnerabilities via:
- GitHub Issues: https://github.com/lancelin111/crawl4ai-skill/issues
- Email: (will be added if needed)
Response Time: 48 hours
Disclosure Policy: 1. Report received → Acknowledged within 48h 2. Fix developed → Released within 7 days (for critical issues) 3. Public disclosure → After fix is released
Security Updates
We commit to:
- ✅ Promptly fix reported vulnerabilities
- ✅ Release security patches within 7 days for critical issues
- ✅ Publish security advisories on GitHub
- ✅ Maintain changelogs with security notes
License
This security policy is licensed under CC BY 4.0.
---
Last Updated: 2026-03-11 Contact: @lancelin111
"""Crawl4AI Skill - Smart web crawling and search for OpenClaw"""
__version__ = "0.1.0"
"""CLI 入口 - Click 命令行接口
提供 crawl4ai-skill 命令行工具。
"""
import asyncio
import json
import signal
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import click
from .search import DuckDuckGoSearcher, SearchError, RateLimitError
from .crawler import SmartCrawler, CrawlError, InvalidURLError, HTTPError
from .parser import ContentParser
# 全局标志,用于优雅退出
_interrupted = False
def handle_interrupt(signum, frame):
"""处理 Ctrl+C 中断"""
global _interrupted
_interrupted = True
click.echo("\n⚠ 收到中断信号,正在优雅退出...", err=True)
# 注册信号处理
signal.signal(signal.SIGINT, handle_interrupt)
@click.group()
@click.version_option(version="0.3.0", prog_name="crawl4ai-skill")
def cli():
"""Crawl4AI Skill - 智能搜索与爬取工具
为 OpenClaw 提供 DuckDuckGo 搜索和智能网页爬取能力。
\b
示例:
crawl4ai-skill search "python web scraping"
crawl4ai-skill crawl https://example.com
crawl4ai-skill crawl-site https://docs.example.com
"""
pass
@cli.command()
@click.argument("query")
@click.option("--num-results", "-n", default=10, help="搜索结果数量 (1-100)")
@click.option("--output", "-o", type=click.Path(), help="输出文件路径 (JSON 格式)")
def search(query: str, num_results: int, output: Optional[str]):
"""搜索网页
使用 DuckDuckGo 搜索,无需 API key。
\b
示例:
crawl4ai-skill search "python web scraping"
crawl4ai-skill search "AI tutorials" --num-results 5
crawl4ai-skill search "machine learning" -o results.json
"""
try:
searcher = DuckDuckGoSearcher()
results = searcher.search(query, num_results)
if not results:
click.echo(f"⚠ 未找到相关结果: {query}", err=True)
raise SystemExit(0)
output_data = {
"query": query,
"num_results": len(results),
"timestamp": datetime.now(timezone.utc).isoformat(),
"results": [r.to_dict() for r in results],
}
if output:
try:
with open(output, "w", encoding="utf-8") as f:
json.dump(output_data, f, indent=2, ensure_ascii=False)
click.echo(f"✓ 搜索结果已保存到 {output}")
except PermissionError:
click.echo(f"✗ 无法写入文件: {output},权限不足", err=True)
raise SystemExit(1)
except OSError as e:
click.echo(f"✗ 无法写入文件: {output},{e}", err=True)
raise SystemExit(1)
else:
click.echo(json.dumps(output_data, indent=2, ensure_ascii=False))
except RateLimitError as e:
click.echo(f"✗ 搜索被限流,请稍后重试: {e}", err=True)
raise SystemExit(1)
except SearchError as e:
click.echo(f"✗ 搜索失败: {e}", err=True)
raise SystemExit(1)
@cli.command()
@click.argument("url")
@click.option(
"--format",
"-f",
default="fit_markdown",
type=click.Choice(["fit_markdown", "markdown_with_citations", "raw_markdown"]),
help="输出格式",
)
@click.option("--output", "-o", type=click.Path(), help="输出文件路径")
@click.option("--wait-for", "-w", help="等待元素加载 (CSS selector)")
@click.option("--timeout", "-t", default=30, help="超时时间(秒)")
@click.option(
"--wait-until",
default="domcontentloaded",
type=click.Choice(["domcontentloaded", "networkidle", "load", "commit"]),
help="等待策略:domcontentloaded(默认), networkidle(动态页面推荐), load, commit",
)
@click.option(
"--delay",
"-d",
default=0.1,
type=float,
help="返回前额外等待时间(秒),用于 JS 动态页面",
)
@click.option("--include-metadata", is_flag=True, help="在输出中包含元数据头")
def crawl(
url: str,
format: str,
output: Optional[str],
wait_for: Optional[str],
timeout: int,
wait_until: str,
delay: float,
include_metadata: bool,
):
"""爬取单个网页
提取网页内容并转换为 Markdown 格式。
\b
示例:
crawl4ai-skill crawl https://example.com
crawl4ai-skill crawl https://example.com --format markdown_with_citations
crawl4ai-skill crawl https://example.com -o page.md
crawl4ai-skill crawl https://xueqiu.com/S/BIDU --wait-until networkidle --delay 2
"""
try:
crawler = SmartCrawler()
result = asyncio.run(
crawler.crawl_page(
url,
format=format,
wait_for=wait_for,
timeout=timeout,
wait_until=wait_until,
delay_before_return_html=delay,
)
)
if result.status == "failed":
click.echo(f"✗ 爬取失败: {result.error}", err=True)
raise SystemExit(1)
# 格式化输出
parser = ContentParser()
markdown = result.markdown
if include_metadata:
metadata = {
"title": result.title,
"url": result.url,
"timestamp": result.crawled_at,
"format": format,
}
markdown = parser.format_markdown(markdown, metadata)
if output:
try:
with open(output, "w", encoding="utf-8") as f:
f.write(markdown)
click.echo(f"✓ 页面已保存到 {output}")
click.echo(f" 标题: {result.title}")
click.echo(f" 链接数: {len(result.links)}")
except PermissionError:
click.echo(f"✗ 无法写入文件: {output},权限不足", err=True)
raise SystemExit(1)
except OSError as e:
click.echo(f"✗ 无法写入文件: {output},{e}", err=True)
raise SystemExit(1)
else:
click.echo(markdown)
except InvalidURLError as e:
click.echo(f"✗ 无效的 URL: {e}", err=True)
raise SystemExit(1)
except HTTPError as e:
click.echo(f"✗ HTTP 错误: {e}", err=True)
raise SystemExit(1)
except CrawlError as e:
click.echo(f"✗ 爬取失败: {e}", err=True)
raise SystemExit(1)
@cli.command("crawl-site")
@click.argument("url")
@click.option("--max-depth", "-d", default=2, help="最大爬取深度 (1-10)")
@click.option("--max-pages", "-p", default=50, help="最大页面数量 (1-1000)")
@click.option("--include-external", is_flag=True, help="包含外部链接")
@click.option(
"--format",
"-f",
default="fit_markdown",
type=click.Choice(["fit_markdown", "markdown_with_citations", "raw_markdown"]),
help="输出格式",
)
@click.option(
"--output-dir",
"-o",
default="./crawl_output",
type=click.Path(),
help="输出目录",
)
@click.option(
"--strategy",
"-s",
default="auto",
type=click.Choice(["auto", "sitemap", "recursive"]),
help="爬取策略",
)
def crawl_site(
url: str,
max_depth: int,
max_pages: int,
include_external: bool,
format: str,
output_dir: str,
strategy: str,
):
"""爬取整个站点
支持 sitemap、llms-full.txt 和递归爬取策略。
\b
示例:
crawl4ai-skill crawl-site https://docs.example.com
crawl4ai-skill crawl-site https://example.com/sitemap.xml --strategy sitemap
crawl4ai-skill crawl-site https://example.com --max-depth 3 --max-pages 100
"""
try:
# 创建输出目录
output_path = Path(output_dir)
try:
output_path.mkdir(parents=True, exist_ok=True)
pages_path = output_path / "pages"
pages_path.mkdir(exist_ok=True)
except PermissionError:
click.echo(f"✗ 无法创建目录: {output_dir},权限不足", err=True)
raise SystemExit(1)
except OSError as e:
click.echo(f"✗ 无法创建目录: {output_dir},{e}", err=True)
raise SystemExit(1)
click.echo(f"开始爬取: {url}")
click.echo(f"策略: {strategy}, 最大深度: {max_depth}, 最大页面: {max_pages}")
crawler = SmartCrawler()
results = asyncio.run(
crawler.crawl_site(
url,
max_depth=max_depth,
max_pages=max_pages,
include_external=include_external,
format=format,
strategy=strategy,
)
)
if not results:
click.echo("⚠ 未爬取到任何页面", err=True)
return
# 保存结果
parser = ContentParser()
index_data = {
"start_url": url,
"crawled_at": datetime.now(timezone.utc).isoformat(),
"strategy": strategy,
"max_depth": max_depth,
"max_pages": max_pages,
"pages": [],
}
success_count = 0
failed_count = 0
for i, result in enumerate(results, 1):
page_id = f"page_{i:03d}"
page_file = f"pages/{page_id}.md"
if result.status == "success":
success_count += 1
# 保存页面内容
try:
with open(output_path / page_file, "w", encoding="utf-8") as f:
metadata = {
"title": result.title,
"url": result.url,
"timestamp": result.crawled_at,
"format": format,
}
content = parser.format_markdown(result.markdown, metadata)
f.write(content)
except (PermissionError, OSError) as e:
click.echo(f" ⚠ 无法保存文件 {page_file}: {e}", err=True)
failed_count += 1
success_count -= 1
else:
failed_count += 1
index_data["pages"].append(
{
"id": page_id,
"url": result.url,
"title": result.title,
"depth": result.depth,
"file": page_file if result.status == "success" else None,
"status": result.status,
"error": result.error,
"links_found": len(result.links),
}
)
click.echo(
f" [{i}/{len(results)}] {result.status}: {result.url[:60]}..."
if len(result.url) > 60
else f" [{i}/{len(results)}] {result.status}: {result.url}"
)
# 保存索引
with open(output_path / "index.json", "w", encoding="utf-8") as f:
json.dump(index_data, f, indent=2, ensure_ascii=False)
# 保存统计信息
stats = {
"total_pages": len(results),
"successful": success_count,
"failed": failed_count,
"total_links_found": sum(len(r.links) for r in results),
}
with open(output_path / "stats.json", "w", encoding="utf-8") as f:
json.dump(stats, f, indent=2, ensure_ascii=False)
click.echo()
click.echo(f"✓ 爬取完成!")
click.echo(f" 成功: {success_count}, 失败: {failed_count}")
click.echo(f" 输出目录: {output_path}")
except InvalidURLError as e:
click.echo(f"✗ 无效的 URL: {e}", err=True)
raise SystemExit(1)
except HTTPError as e:
click.echo(f"✗ HTTP 错误: {e}", err=True)
raise SystemExit(1)
except CrawlError as e:
click.echo(f"✗ 爬取失败: {e}", err=True)
raise SystemExit(1)
@cli.command("search-and-crawl")
@click.argument("query")
@click.option("--num-results", "-n", default=5, help="搜索结果数量")
@click.option("--crawl-top", "-c", default=3, help="爬取前 N 个结果")
@click.option(
"--format",
"-f",
default="fit_markdown",
type=click.Choice(["fit_markdown", "markdown_with_citations", "raw_markdown"]),
help="输出格式",
)
@click.option(
"--output-dir",
"-o",
default="./search_crawl_output",
type=click.Path(),
help="输出目录",
)
def search_and_crawl(
query: str,
num_results: int,
crawl_top: int,
format: str,
output_dir: str,
):
"""搜索并爬取
先搜索,再爬取前 N 个结果。
\b
示例:
crawl4ai-skill search-and-crawl "python web scraping tutorials"
crawl4ai-skill search-and-crawl "AI tutorials" --num-results 10 --crawl-top 5
"""
global _interrupted
# 参数验证: crawl_top 不能超过 num_results
if crawl_top > num_results:
click.echo(f"⚠ crawl-top ({crawl_top}) 大于 num-results ({num_results}),已自动调整为 {num_results}", err=True)
crawl_top = num_results
try:
# 创建输出目录
output_path = Path(output_dir)
try:
output_path.mkdir(parents=True, exist_ok=True)
except PermissionError:
click.echo(f"✗ 无法创建目录: {output_dir},权限不足", err=True)
raise SystemExit(1)
except OSError as e:
click.echo(f"✗ 无法创建目录: {output_dir},{e}", err=True)
raise SystemExit(1)
# 1. 搜索
click.echo(f"搜索: {query}")
searcher = DuckDuckGoSearcher()
search_results = searcher.search(query, num_results)
if not search_results:
click.echo(f"⚠ 未找到相关结果: {query}", err=True)
# 保存空结果
search_data = {
"query": query,
"timestamp": datetime.now(timezone.utc).isoformat(),
"results": [],
}
with open(output_path / "search_results.json", "w", encoding="utf-8") as f:
json.dump(search_data, f, indent=2, ensure_ascii=False)
click.echo(f"✓ 空结果已保存到 {output_path}")
return
click.echo(f" 找到 {len(search_results)} 条结果")
# 保存搜索结果
search_data = {
"query": query,
"timestamp": datetime.now(timezone.utc).isoformat(),
"results": [r.to_dict() for r in search_results],
}
with open(output_path / "search_results.json", "w", encoding="utf-8") as f:
json.dump(search_data, f, indent=2, ensure_ascii=False)
# 2. 爬取前 N 个
urls_to_crawl = [r.url for r in search_results[:crawl_top]]
click.echo(f"\n爬取前 {len(urls_to_crawl)} 个结果:")
crawler = SmartCrawler()
parser = ContentParser()
crawl_results = []
for i, url in enumerate(urls_to_crawl, 1):
# 检查是否被中断
if _interrupted:
click.echo("\n⚠ 用户中断,保存已完成的结果...", err=True)
break
click.echo(f" [{i}/{len(urls_to_crawl)}] {url[:60]}...")
try:
result = asyncio.run(crawler.crawl_page(url, format=format))
crawl_results.append(result)
# 保存页面
if result.status == "success":
filename = f"page_{i:02d}.md"
with open(output_path / filename, "w", encoding="utf-8") as f:
metadata = {
"title": result.title,
"url": result.url,
"timestamp": result.crawled_at,
"format": format,
}
content = parser.format_markdown(result.markdown, metadata)
f.write(content)
click.echo(f" ✓ 已保存: {filename}")
else:
click.echo(f" ✗ 失败: {result.error}")
except Exception as e:
click.echo(f" ✗ 错误: {e}")
# 保存索引
index_data = {
"query": query,
"crawled_at": datetime.now(timezone.utc).isoformat(),
"search_results": len(search_results),
"crawled_pages": len([r for r in crawl_results if r.status == "success"]),
"interrupted": _interrupted,
"pages": [
{
"url": r.url,
"title": r.title,
"status": r.status,
}
for r in crawl_results
],
}
with open(output_path / "index.json", "w", encoding="utf-8") as f:
json.dump(index_data, f, indent=2, ensure_ascii=False)
click.echo(f"\n✓ 完成! 输出目录: {output_path}")
except RateLimitError as e:
click.echo(f"✗ 搜索被限流,请稍后重试: {e}", err=True)
raise SystemExit(1)
except SearchError as e:
click.echo(f"✗ 搜索失败: {e}", err=True)
raise SystemExit(1)
except InvalidURLError as e:
click.echo(f"✗ 无效的 URL: {e}", err=True)
raise SystemExit(1)
except CrawlError as e:
click.echo(f"✗ 爬取失败: {e}", err=True)
raise SystemExit(1)
if __name__ == "__main__":
cli()
"""配置管理 - 使用 Pydantic"""
from typing import Literal, Optional
from pydantic import BaseModel, Field
class CrawlConfig(BaseModel):
"""爬取配置"""
timeout: int = Field(default=30, ge=1, le=300, description="超时时间(秒)")
max_depth: int = Field(default=2, ge=1, le=10, description="最大爬取深度")
max_pages: int = Field(default=50, ge=1, le=1000, description="最大页面数量")
include_external: bool = Field(default=False, description="是否包含外部链接")
format: Literal["fit_markdown", "markdown_with_citations", "raw_markdown"] = Field(
default="fit_markdown", description="输出格式"
)
wait_for: Optional[str] = Field(default=None, description="等待元素(CSS selector)")
class SearchConfig(BaseModel):
"""搜索配置"""
num_results: int = Field(default=10, ge=1, le=100, description="搜索结果数量")
timeout: int = Field(default=10, ge=1, le=60, description="超时时间(秒)")
class SiteCrawlConfig(CrawlConfig):
"""全站爬取配置"""
strategy: Literal["auto", "sitemap", "recursive"] = Field(
default="auto", description="爬取策略"
)
output_dir: str = Field(default="./crawl_output", description="输出目录")
class SearchAndCrawlConfig(BaseModel):
"""搜索并爬取配置"""
num_results: int = Field(default=5, ge=1, le=100, description="搜索结果数量")
crawl_top: int = Field(default=3, ge=1, le=20, description="爬取前 N 个结果")
format: Literal["fit_markdown", "markdown_with_citations", "raw_markdown"] = Field(
default="fit_markdown", description="输出格式"
)
output_dir: str = Field(default="./search_crawl_output", description="输出目录")
"""爬取模块 - 基于 crawl4ai 的网页爬取
提供单页爬取和全站爬取功能,支持多种 Markdown 输出格式。
"""
import asyncio
import random
try:
import defusedxml.ElementTree as ET
except ImportError:
# Fallback to standard library with manual sanitization
import xml.etree.ElementTree as ET
import warnings
warnings.warn("defusedxml not installed, using xml.etree.ElementTree (less secure)")
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional, List, Set, Dict, Any
from urllib.parse import urljoin, urlparse
import logging
import re
logger = logging.getLogger(__name__)
def _validate_url_scheme(url: str) -> None:
"""验证 URL 只允许 http/https 协议
Args:
url: 要验证的 URL
Raises:
ValueError: 如果 URL 使用了不安全的协议(如 file://)
"""
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https'):
raise ValueError(f"不安全的 URL 协议: {parsed.scheme}。只允许 http 和 https。")
class CrawlError(Exception):
"""爬取相关错误的基类"""
pass
class CrawlTimeoutError(CrawlError):
"""超时错误"""
pass
class CrawlNetworkError(CrawlError):
"""网络错误"""
pass
class InvalidURLError(CrawlError):
"""无效 URL 错误"""
pass
class HTTPError(CrawlError):
"""HTTP 错误"""
def __init__(self, message: str, status_code: int = None):
super().__init__(message)
self.status_code = status_code
def validate_url(url: str) -> None:
"""验证 URL 是否有效
Args:
url: 待验证的 URL
Raises:
InvalidURLError: URL 无效
"""
if not url or not url.strip():
raise InvalidURLError("URL 不能为空")
url = url.strip()
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https'):
raise InvalidURLError(f"不支持的协议: {parsed.scheme},只支持 http/https")
if not parsed.netloc:
raise InvalidURLError(f"无效的 URL: {url}")
@dataclass
class CrawlResult:
"""爬取结果数据结构
Attributes:
url: 爬取的 URL
title: 页面标题
markdown: Markdown 内容
links: 页面中的链接
status: 爬取状态 (success/failed)
error: 错误信息(如果失败)
depth: 爬取深度
crawled_at: 爬取时间
"""
url: str
title: str
markdown: str
links: List[str] = field(default_factory=list)
status: str = "success"
error: Optional[str] = None
depth: int = 0
crawled_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"url": self.url,
"title": self.title,
"markdown": self.markdown,
"links": self.links,
"status": self.status,
"error": self.error,
"depth": self.depth,
"crawled_at": self.crawled_at,
}
class SmartCrawler:
"""智能爬虫
基于 crawl4ai 提供单页和全站爬取功能。
Example:
>>> crawler = SmartCrawler()
>>> result = await crawler.crawl_page("https://example.com")
>>> print(result.markdown)
"""
def __init__(self, verbose: bool = False):
"""初始化爬虫
Args:
verbose: 是否输出详细日志
"""
self.verbose = verbose
if verbose:
logging.basicConfig(level=logging.DEBUG)
async def crawl_page(
self,
url: str,
format: str = "fit_markdown",
wait_for: Optional[str] = None,
timeout: int = 30,
wait_until: str = "domcontentloaded",
delay_before_return_html: float = 0.1,
) -> CrawlResult:
"""爬取单个页面
Args:
url: 目标 URL
format: 输出格式 (fit_markdown/markdown_with_citations/raw_markdown)
wait_for: 等待元素加载(CSS selector)
timeout: 超时时间(秒)
wait_until: 等待策略 (domcontentloaded/networkidle/load/commit)
delay_before_return_html: 返回 HTML 前的额外延迟(秒),用于等待 JS 执行
Returns:
爬取结果
Raises:
CrawlTimeoutError: 超时
CrawlNetworkError: 网络错误
CrawlError: 其他爬取错误
"""
try:
from crawl4ai import AsyncWebCrawler
from crawl4ai.async_configs import CrawlerRunConfig
except ImportError:
raise CrawlError("crawl4ai 未安装,请运行: pip install crawl4ai && crawl4ai-setup")
# 验证 URL
validate_url(url)
try:
logger.info(f"开始爬取: {url}")
# 创建配置对象
config = CrawlerRunConfig(
wait_for=wait_for if wait_for else None,
wait_until=wait_until,
page_timeout=timeout * 1000, # 转换为毫秒
delay_before_return_html=delay_before_return_html,
)
async with AsyncWebCrawler(verbose=self.verbose) as crawler:
result_container = await crawler.arun(
url=url,
config=config,
)
if not result_container.success:
error_msg = "Unknown error"
if hasattr(result_container, 'error_message'):
error_msg = result_container.error_message or error_msg
return CrawlResult(
url=url,
title="",
markdown="",
links=[],
status="failed",
error=error_msg,
)
# crawl4ai 0.8.0: 结果是 CrawlResultContainer,需要获取第一个结果
first_result = result_container[0] if hasattr(result_container, '__getitem__') else result_container
# 获取标题 - 从 metadata 中获取
title = ""
if hasattr(first_result, 'metadata') and first_result.metadata:
title = first_result.metadata.get('title', '') or ""
# 根据 format 选择输出格式
# crawl4ai 0.8.0: 简化处理,直接使用 markdown 字符串
markdown_str = str(result_container.markdown) if result_container.markdown else ""
if format == "fit_markdown":
# 尝试获取 fit_markdown,如果不存在则使用原始 markdown
try:
fit_md = first_result.markdown.fit_markdown if hasattr(first_result.markdown, 'fit_markdown') else None
markdown = fit_md if fit_md else markdown_str
except (AttributeError, TypeError):
markdown = markdown_str
elif format == "markdown_with_citations":
# 尝试获取带引用的 markdown
try:
if hasattr(first_result, 'markdown_v2') and first_result.markdown_v2:
citations_md = getattr(first_result.markdown_v2, 'markdown_with_citations', None)
markdown = citations_md if citations_md else markdown_str
else:
markdown = markdown_str
except (AttributeError, TypeError):
markdown = markdown_str
else:
markdown = markdown_str
# 提取链接
links_dict = result_container.links if hasattr(result_container, 'links') else {}
internal_links = links_dict.get("internal", []) if links_dict else []
external_links = links_dict.get("external", []) if links_dict else []
# 将链接对象转换为 URL 字符串
all_links = []
for link in internal_links:
if isinstance(link, dict):
all_links.append(link.get("href", ""))
else:
all_links.append(str(link))
for link in external_links:
if isinstance(link, dict):
all_links.append(link.get("href", ""))
else:
all_links.append(str(link))
logger.info(f"爬取完成: {url}, 标题: {title}")
return CrawlResult(
url=url,
title=title,
markdown=markdown,
links=all_links,
status="success",
)
except asyncio.TimeoutError as e:
logger.error(f"爬取超时: {url}")
raise CrawlTimeoutError(f"爬取超时: {url}") from e
except Exception as e:
logger.error(f"爬取失败: {url}, 错误: {e}")
raise CrawlError(f"爬取失败: {e}") from e
async def crawl_site(
self,
start_url: str,
max_depth: int = 2,
max_pages: int = 50,
include_external: bool = False,
format: str = "fit_markdown",
strategy: str = "auto",
) -> List[CrawlResult]:
"""爬取整个站点
Args:
start_url: 起始 URL
max_depth: 最大爬取深度
max_pages: 最大页面数量
include_external: 是否包含外部链接
format: 输出格式
strategy: 爬取策略 (auto/sitemap/recursive)
Returns:
爬取结果列表
"""
# 自动识别策略
if strategy == "auto":
strategy = self._detect_strategy(start_url)
logger.info(f"开始全站爬取: {start_url}, 策略: {strategy}")
# 根据策略执行爬取
if strategy == "sitemap":
return await self._crawl_from_sitemap(start_url, max_pages, format)
elif strategy == "txt":
return await self._crawl_from_txt(start_url, max_pages, format)
else:
return await self._crawl_recursive(
start_url, max_depth, max_pages, include_external, format
)
def _detect_strategy(self, url: str) -> str:
"""检测爬取策略
Args:
url: URL
Returns:
策略名称
"""
if url.endswith("sitemap.xml") or "sitemap" in url.lower():
return "sitemap"
elif url.endswith(".txt") and ("llms" in url.lower() or "links" in url.lower()):
return "txt"
else:
return "recursive"
async def _crawl_from_sitemap(
self, sitemap_url: str, max_pages: int, format: str
) -> List[CrawlResult]:
"""从 sitemap.xml 爬取
Args:
sitemap_url: sitemap URL
max_pages: 最大页面数
format: 输出格式
Returns:
爬取结果列表
"""
# 验证 URL
validate_url(sitemap_url)
try:
import aiohttp
except ImportError:
import urllib.request
import urllib.error
# 使用同步请求作为回退
try:
# 验证 URL 协议安全性
_validate_url_scheme(sitemap_url)
with urllib.request.urlopen(sitemap_url, timeout=30) as response: # nosec B310 - URL scheme validated
content = response.read().decode('utf-8')
except urllib.error.HTTPError as e:
raise HTTPError(f"获取 sitemap 失败: HTTP {e.code}", status_code=e.code)
except urllib.error.URLError as e:
raise CrawlNetworkError(f"获取 sitemap 失败: {e.reason}")
else:
async with aiohttp.ClientSession() as session:
try:
async with session.get(sitemap_url) as response:
if response.status >= 400:
raise HTTPError(f"获取 sitemap 失败: HTTP {response.status}", status_code=response.status)
content = await response.text()
except aiohttp.ClientError as e:
raise CrawlNetworkError(f"获取 sitemap 失败: {e}")
# 解析 XML
try:
# Using defusedxml.ElementTree when available (fallback to xml.etree.ElementTree)
root = ET.fromstring(content) # nosec B314 - using defusedxml when available
# 移除命名空间以简化解析
for elem in root.iter():
if '}' in elem.tag:
elem.tag = elem.tag.split('}', 1)[1]
# 检查是否是 sitemap index(嵌套 sitemap)
sitemap_locs = [loc.text for loc in root.findall('.//sitemap/loc') if loc.text]
if sitemap_locs:
logger.info(f"发现 sitemap index,包含 {len(sitemap_locs)} 个子 sitemap")
# 递归获取所有子 sitemap 的 URL
all_urls = []
for sub_sitemap in sitemap_locs:
if len(all_urls) >= max_pages:
break
try:
sub_results = await self._crawl_from_sitemap(
sub_sitemap, max_pages - len(all_urls), format
)
# 只收集 URL,稍后统一爬取
for r in sub_results:
if len(all_urls) >= max_pages:
break
all_urls.append(r.url)
except Exception as e:
logger.warning(f"获取子 sitemap 失败: {sub_sitemap}, 错误: {e}")
# 对于 sitemap index,直接返回已爬取的结果
return sub_results if sub_results else []
urls = [loc.text for loc in root.findall('.//loc') if loc.text]
except ET.ParseError as e:
logger.error(f"解析 sitemap 失败: {sitemap_url}")
raise CrawlError(f"解析 sitemap XML 失败: {e}")
if not urls:
logger.warning(f"sitemap 中未发现任何 URL: {sitemap_url}")
return []
# 限制页面数量
urls = urls[:max_pages]
logger.info(f"从 sitemap 发现 {len(urls)} 个 URL")
# 爬取所有 URL,添加延时
results = []
for i, url in enumerate(urls):
try:
result = await self.crawl_page(url, format=format)
results.append(result)
except Exception as e:
logger.error(f"爬取失败: {url}, 错误: {e}")
results.append(CrawlResult(
url=url,
title="",
markdown="",
status="failed",
error=str(e),
))
if len(results) >= max_pages:
break
# 随机延迟 0.1~0.3 秒,模拟人类行为,避免触发速率限制
await asyncio.sleep(random.uniform(0.1, 0.3))
return results
async def _crawl_from_txt(
self, txt_url: str, max_pages: int, format: str
) -> List[CrawlResult]:
"""从 txt 文件爬取(llms-full.txt 等)
Args:
txt_url: txt 文件 URL
max_pages: 最大页面数
format: 输出格式
Returns:
爬取结果列表
"""
# 验证 URL
validate_url(txt_url)
try:
import aiohttp
except ImportError:
import urllib.request
import urllib.error
try:
# 验证 URL 协议安全性
_validate_url_scheme(txt_url)
with urllib.request.urlopen(txt_url, timeout=30) as response: # nosec B310 - URL scheme validated
content = response.read().decode('utf-8')
except urllib.error.HTTPError as e:
raise HTTPError(f"获取 txt 文件失败: HTTP {e.code}", status_code=e.code)
except urllib.error.URLError as e:
raise CrawlNetworkError(f"获取 txt 文件失败: {e.reason}")
else:
async with aiohttp.ClientSession() as session:
try:
async with session.get(txt_url) as response:
if response.status >= 400:
raise HTTPError(f"获取 txt 文件失败: HTTP {response.status}", status_code=response.status)
content = await response.text()
except aiohttp.ClientError as e:
raise CrawlNetworkError(f"获取 txt 文件失败: {e}")
# 解析 URL(每行一个)
urls = []
for line in content.strip().split('\n'):
line = line.strip()
if line and (line.startswith('http://') or line.startswith('https://')):
urls.append(line)
if not urls:
logger.warning(f"txt 文件中未发现任何 URL: {txt_url}")
return []
urls = urls[:max_pages]
logger.info(f"从 txt 文件发现 {len(urls)} 个 URL")
# 爬取所有 URL,添加延时
results = []
for i, url in enumerate(urls):
try:
result = await self.crawl_page(url, format=format)
results.append(result)
except Exception as e:
logger.error(f"爬取失败: {url}, 错误: {e}")
results.append(CrawlResult(
url=url,
title="",
markdown="",
status="failed",
error=str(e),
))
if len(results) >= max_pages:
break
# 随机延迟 0.1~0.3 秒,模拟人类行为,避免触发速率限制
await asyncio.sleep(random.uniform(0.1, 0.3))
return results
async def _crawl_recursive(
self,
start_url: str,
max_depth: int,
max_pages: int,
include_external: bool,
format: str,
) -> List[CrawlResult]:
"""递归爬取(BFS)
Args:
start_url: 起始 URL
max_depth: 最大深度
max_pages: 最大页面数
include_external: 是否包含外部链接
format: 输出格式
Returns:
爬取结果列表
"""
# 验证起始 URL
validate_url(start_url)
# 解析基础域名
parsed_start = urlparse(start_url)
base_domain = parsed_start.netloc
# BFS 队列: (url, depth)
queue: List[tuple] = [(start_url, 0)]
visited: Set[str] = set()
results: List[CrawlResult] = []
while queue and len(results) < max_pages:
url, depth = queue.pop(0)
# 跳过已访问的 URL
if url in visited:
continue
# 跳过超过最大深度的 URL
if depth > max_depth:
continue
visited.add(url)
# 爬取页面
try:
result = await self.crawl_page(url, format=format)
result.depth = depth
results.append(result)
logger.info(f"已爬取 {len(results)}/{max_pages} 页, 深度: {depth}")
# 提取新链接加入队列
if depth < max_depth:
for link in result.links:
if not link or link in visited:
continue
# 标准化链接
if link.startswith('/'):
link = urljoin(url, link)
parsed_link = urlparse(link)
# 检查是否为外部链接
is_external = parsed_link.netloc != base_domain
if is_external and not include_external:
continue
# 跳过非 HTTP 链接
if parsed_link.scheme not in ('http', 'https'):
continue
# 跳过锚点、javascript 等
if link.startswith('#') or link.startswith('javascript:'):
continue
queue.append((link, depth + 1))
except Exception as e:
logger.error(f"爬取失败: {url}, 错误: {e}")
results.append(CrawlResult(
url=url,
title="",
markdown="",
status="failed",
error=str(e),
depth=depth,
))
# 随机延迟 0.1~0.3 秒,模拟人类行为,避免触发速率限制
await asyncio.sleep(random.uniform(0.1, 0.3))
logger.info(f"递归爬取完成,共爬取 {len(results)} 页")
return results
"""解析模块 - 内容格式化和后处理
提供 Markdown 格式化、引用添加等功能。
"""
from datetime import datetime, timezone
from typing import Dict, Any, List, Optional
import re
class ContentParser:
"""内容解析器
用于格式化和后处理爬取的内容。
Example:
>>> parser = ContentParser()
>>> formatted = parser.format_markdown(content, metadata)
>>> print(formatted)
"""
def format_markdown(
self,
content: str,
metadata: Dict[str, Any],
include_header: bool = True,
) -> str:
"""格式化 Markdown 输出
Args:
content: 原始 markdown 内容
metadata: 元数据(URL、标题、时间等)
include_header: 是否包含页头
Returns:
格式化后的 markdown
"""
if not include_header:
return content
title = metadata.get("title", "Untitled")
url = metadata.get("url", "")
timestamp = metadata.get("timestamp", datetime.now(timezone.utc).isoformat())
format_type = metadata.get("format", "markdown")
header = f"""# {title}
## Metadata
- **URL**: {url}
- **Crawled at**: {timestamp}
- **Format**: {format_type}
---
"""
return header + content
def add_citations(self, content: str, links: List[str]) -> str:
"""添加引用列表
Args:
content: markdown 内容
links: 链接列表
Returns:
带引用的 markdown
"""
if not links:
return content
# 过滤空链接
valid_links = [link for link in links if link and link.strip()]
if not valid_links:
return content
citations = "\n\n---\n\n## References\n\n"
for i, link in enumerate(valid_links, 1):
citations += f"[{i}]: {link}\n"
return content + citations
def clean_markdown(self, content: str) -> str:
"""清理 Markdown 内容
移除多余空行、修复格式问题等。
Args:
content: 原始 markdown
Returns:
清理后的 markdown
"""
if not content:
return ""
# 移除多余空行(保留最多两个连续空行)
content = re.sub(r'\n{4,}', '\n\n\n', content)
# 移除行尾空白
content = '\n'.join(line.rstrip() for line in content.split('\n'))
# 确保文件以单个换行结尾
content = content.strip() + '\n'
return content
def extract_title_from_markdown(self, content: str) -> Optional[str]:
"""从 Markdown 中提取标题
Args:
content: markdown 内容
Returns:
提取的标题或 None
"""
if not content:
return None
# 查找第一个 h1 标题
match = re.search(r'^#\s+(.+)$', content, re.MULTILINE)
if match:
return match.group(1).strip()
return None
def truncate_content(
self,
content: str,
max_length: int = 10000,
suffix: str = "\n\n... (内容已截断)"
) -> str:
"""截断内容
Args:
content: 原始内容
max_length: 最大长度
suffix: 截断后缀
Returns:
截断后的内容
"""
if not content or len(content) <= max_length:
return content
# 在适当位置截断(尝试在段落边界)
truncated = content[:max_length]
# 尝试在最后一个段落结束处截断
last_para = truncated.rfind('\n\n')
if last_para > max_length * 0.8:
truncated = truncated[:last_para]
return truncated + suffix
def merge_results(
self,
results: List[Dict[str, Any]],
separator: str = "\n\n---\n\n"
) -> str:
"""合并多个爬取结果
Args:
results: 爬取结果列表
separator: 分隔符
Returns:
合并后的 markdown
"""
if not results:
return ""
parts = []
for result in results:
title = result.get("title", "Untitled")
url = result.get("url", "")
content = result.get("markdown", "")
part = f"## {title}\n\n**Source**: {url}\n\n{content}"
parts.append(part)
return separator.join(parts)
"""搜索模块 - 提供 DuckDuckGo 网页搜索
这个模块封装了 DuckDuckGo 搜索功能,提供简洁的 API 用于网页搜索。
"""
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from typing import List, Optional
import logging
# 兼容新旧包名
try:
from ddgs import DDGS
from ddgs.exceptions import DDGSException as DuckDuckGoSearchException
except ImportError:
from duckduckgo_search import DDGS
from duckduckgo_search.exceptions import DuckDuckGoSearchException
logger = logging.getLogger(__name__)
class SearchError(Exception):
"""搜索相关错误的基类"""
pass
class EmptyQueryError(SearchError):
"""空查询错误"""
pass
class SearchNetworkError(SearchError):
"""网络错误"""
pass
class RateLimitError(SearchError):
"""限流错误"""
pass
@dataclass
class SearchResult:
"""搜索结果数据结构
Attributes:
title: 结果标题
url: 结果 URL
snippet: 结果摘要
timestamp: 搜索时间戳 (ISO 格式)
"""
title: str
url: str
snippet: str
timestamp: str
def to_dict(self) -> dict:
"""转换为字典"""
return asdict(self)
class DuckDuckGoSearcher:
"""DuckDuckGo 搜索器
使用 DuckDuckGo 进行网页搜索,无需 API key。
Example:
>>> searcher = DuckDuckGoSearcher()
>>> results = searcher.search("python web scraping", num_results=5)
>>> for r in results:
... print(f"{r.title}: {r.url}")
"""
def __init__(self, timeout: int = 10):
"""初始化搜索器
Args:
timeout: 请求超时时间(秒)
"""
self.timeout = timeout
self._ddgs: Optional[DDGS] = None
@property
def ddgs(self) -> DDGS:
"""懒加载 DDGS 实例"""
if self._ddgs is None:
try:
self._ddgs = DDGS(timeout=self.timeout)
except TypeError:
# 新版 ddgs 包可能不支持 timeout 参数
self._ddgs = DDGS()
return self._ddgs
def search(self, query: str, num_results: int = 10) -> List[SearchResult]:
"""执行搜索
Args:
query: 搜索关键词
num_results: 返回结果数量 (1-100)
Returns:
搜索结果列表
Raises:
EmptyQueryError: 查询为空
SearchNetworkError: 网络请求失败
SearchError: 其他搜索错误
"""
# 验证查询
if not query or not query.strip():
raise EmptyQueryError("搜索查询不能为空")
query = query.strip()
# 限制结果数量
num_results = max(1, min(100, num_results))
try:
logger.info(f"搜索: '{query}', 期望结果数: {num_results}")
# 执行搜索
results = self.ddgs.text(
query,
max_results=num_results,
safesearch="moderate",
)
# 获取当前时间戳
timestamp = datetime.now(timezone.utc).isoformat()
# 转换为 SearchResult 对象
search_results = []
for r in results:
result = SearchResult(
title=r.get("title", ""),
url=r.get("href", ""),
snippet=r.get("body", ""),
timestamp=timestamp,
)
search_results.append(result)
logger.info(f"搜索完成,返回 {len(search_results)} 条结果")
return search_results
except DuckDuckGoSearchException as e:
logger.error(f"DuckDuckGo 搜索失败: {e}")
raise SearchNetworkError(f"搜索请求失败: {e}") from e
except Exception as e:
logger.error(f"搜索时发生未知错误: {e}")
raise SearchError(f"搜索失败: {e}") from e
def search_news(self, query: str, num_results: int = 10) -> List[SearchResult]:
"""搜索新闻
Args:
query: 搜索关键词
num_results: 返回结果数量
Returns:
新闻搜索结果列表
"""
if not query or not query.strip():
raise EmptyQueryError("搜索查询不能为空")
query = query.strip()
num_results = max(1, min(100, num_results))
try:
logger.info(f"搜索新闻: '{query}'")
results = self.ddgs.news(query, max_results=num_results)
timestamp = datetime.now(timezone.utc).isoformat()
search_results = []
for r in results:
result = SearchResult(
title=r.get("title", ""),
url=r.get("url", ""),
snippet=r.get("body", ""),
timestamp=r.get("date", timestamp),
)
search_results.append(result)
return search_results
except DuckDuckGoSearchException as e:
raise SearchNetworkError(f"新闻搜索请求失败: {e}") from e
except Exception as e:
raise SearchError(f"新闻搜索失败: {e}") from e
"""工具函数"""
import re
from urllib.parse import urlparse, urljoin
from typing import List, Optional
def is_valid_url(url: str) -> bool:
"""检查 URL 是否有效
Args:
url: 待检查的 URL
Returns:
是否有效
"""
if not url:
return False
try:
result = urlparse(url)
return all([result.scheme in ('http', 'https'), result.netloc])
except Exception:
return False
def normalize_url(url: str, base_url: Optional[str] = None) -> Optional[str]:
"""标准化 URL
Args:
url: 待标准化的 URL
base_url: 基础 URL(用于相对路径)
Returns:
标准化后的 URL 或 None
"""
if not url:
return None
url = url.strip()
# 跳过特殊链接
if url.startswith(('#', 'javascript:', 'mailto:', 'tel:')):
return None
# 处理相对路径
if base_url and not url.startswith(('http://', 'https://')):
url = urljoin(base_url, url)
# 移除 fragment
parsed = urlparse(url)
url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
if parsed.query:
url += f"?{parsed.query}"
return url if is_valid_url(url) else None
def get_domain(url: str) -> Optional[str]:
"""获取 URL 的域名
Args:
url: URL
Returns:
域名或 None
"""
if not url:
return None
try:
parsed = urlparse(url)
return parsed.netloc
except Exception:
return None
def is_same_domain(url1: str, url2: str) -> bool:
"""检查两个 URL 是否属于同一域名
Args:
url1: 第一个 URL
url2: 第二个 URL
Returns:
是否同域
"""
domain1 = get_domain(url1)
domain2 = get_domain(url2)
return domain1 is not None and domain1 == domain2
def extract_urls_from_text(text: str) -> List[str]:
"""从文本中提取 URL
Args:
text: 文本内容
Returns:
URL 列表
"""
if not text:
return []
# 匹配 URL 的正则表达式
url_pattern = r'https?://[^\s<>"\')\]}>]+'
urls = re.findall(url_pattern, text)
# 过滤和清理
valid_urls = []
for url in urls:
# 移除末尾的标点符号
url = url.rstrip('.,;:!?')
if is_valid_url(url):
valid_urls.append(url)
return list(set(valid_urls)) # 去重
def truncate_string(s: str, max_length: int = 100, suffix: str = "...") -> str:
"""截断字符串
Args:
s: 原字符串
max_length: 最大长度
suffix: 截断后缀
Returns:
截断后的字符串
"""
if not s or len(s) <= max_length:
return s
return s[: max_length - len(suffix)] + suffix
def sanitize_filename(filename: str) -> str:
"""清理文件名,移除非法字符
Args:
filename: 原文件名
Returns:
清理后的文件名
"""
if not filename:
return "untitled"
# 移除非法字符
filename = re.sub(r'[<>:"/\\|?*]', '', filename)
# 移除控制字符
filename = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', filename)
# 移除首尾空白
filename = filename.strip()
# 限制长度
filename = filename[:200] if len(filename) > 200 else filename
return filename or "untitled"
"""Tests for crawl4ai-skill"""
"""爬取模块单元测试"""
import pytest
from unittest.mock import Mock, patch, AsyncMock, MagicMock
import asyncio
import sys
sys.path.insert(0, "/Users/lancelin/.openclaw/workspace-crawler/crawl4ai-skill")
from src.crawler import (
SmartCrawler,
CrawlResult,
CrawlError,
CrawlTimeoutError,
CrawlNetworkError,
InvalidURLError,
HTTPError,
validate_url,
)
class TestCrawlResult:
"""CrawlResult 数据类测试"""
def test_create_crawl_result(self):
"""测试创建爬取结果"""
result = CrawlResult(
url="https://example.com",
title="Example",
markdown="# Example",
links=["https://example.com/page1"],
status="success",
)
assert result.url == "https://example.com"
assert result.title == "Example"
assert result.markdown == "# Example"
assert result.status == "success"
assert result.error is None
def test_create_failed_result(self):
"""测试创建失败结果"""
result = CrawlResult(
url="https://example.com",
title="",
markdown="",
status="failed",
error="Connection refused",
)
assert result.status == "failed"
assert result.error == "Connection refused"
def test_to_dict(self):
"""测试转换为字典"""
result = CrawlResult(
url="https://example.com",
title="Test",
markdown="Content",
)
d = result.to_dict()
assert d["url"] == "https://example.com"
assert d["title"] == "Test"
assert d["markdown"] == "Content"
assert "crawled_at" in d
class TestSmartCrawler:
"""SmartCrawler 测试"""
def test_init_default(self):
"""测试默认初始化"""
crawler = SmartCrawler()
assert crawler.verbose is False
def test_init_verbose(self):
"""测试启用详细日志"""
crawler = SmartCrawler(verbose=True)
assert crawler.verbose is True
def test_detect_strategy_sitemap(self):
"""测试检测 sitemap 策略"""
crawler = SmartCrawler()
assert crawler._detect_strategy("https://example.com/sitemap.xml") == "sitemap"
assert crawler._detect_strategy("https://example.com/SITEMAP.xml") == "sitemap"
def test_detect_strategy_txt(self):
"""测试检测 txt 策略"""
crawler = SmartCrawler()
assert crawler._detect_strategy("https://example.com/llms-full.txt") == "txt"
assert crawler._detect_strategy("https://example.com/llms.txt") == "txt"
def test_detect_strategy_recursive(self):
"""测试检测递归策略"""
crawler = SmartCrawler()
assert crawler._detect_strategy("https://example.com") == "recursive"
assert crawler._detect_strategy("https://example.com/docs") == "recursive"
class TestCrawlExceptions:
"""爬取异常测试"""
def test_crawl_error_base_class(self):
"""测试基础异常类"""
error = CrawlError("test error")
assert str(error) == "test error"
assert isinstance(error, Exception)
def test_timeout_error(self):
"""测试超时异常"""
error = CrawlTimeoutError("timeout")
assert isinstance(error, CrawlError)
def test_network_error(self):
"""测试网络异常"""
error = CrawlNetworkError("network failed")
assert isinstance(error, CrawlError)
def test_invalid_url_error(self):
"""测试无效 URL 异常"""
error = InvalidURLError("invalid url")
assert str(error) == "invalid url"
assert isinstance(error, CrawlError)
def test_http_error(self):
"""测试 HTTP 异常"""
error = HTTPError("not found", status_code=404)
assert str(error) == "not found"
assert error.status_code == 404
assert isinstance(error, CrawlError)
def test_http_error_without_status_code(self):
"""测试 HTTP 异常(无状态码)"""
error = HTTPError("error")
assert error.status_code is None
class TestValidateUrl:
"""URL 验证函数测试"""
def test_validate_url_valid(self):
"""测试有效 URL"""
validate_url("https://example.com")
validate_url("http://example.com/path")
validate_url("https://example.com:8080/path?query=1")
def test_validate_url_empty(self):
"""测试空 URL"""
with pytest.raises(InvalidURLError) as exc_info:
validate_url("")
assert "不能为空" in str(exc_info.value)
def test_validate_url_none(self):
"""测试 None URL"""
with pytest.raises(InvalidURLError) as exc_info:
validate_url(None)
assert "不能为空" in str(exc_info.value)
def test_validate_url_whitespace(self):
"""测试空白 URL"""
with pytest.raises(InvalidURLError) as exc_info:
validate_url(" ")
assert "不能为空" in str(exc_info.value)
def test_validate_url_invalid_scheme(self):
"""测试无效协议"""
with pytest.raises(InvalidURLError) as exc_info:
validate_url("ftp://example.com")
assert "不支持的协议" in str(exc_info.value)
def test_validate_url_no_host(self):
"""测试无主机名"""
with pytest.raises(InvalidURLError) as exc_info:
validate_url("https://")
assert "无效的 URL" in str(exc_info.value)
@pytest.mark.asyncio
class TestSmartCrawlerAsync:
"""SmartCrawler 异步测试"""
async def test_crawl_page_without_crawl4ai(self):
"""测试未安装 crawl4ai 时的错误处理"""
crawler = SmartCrawler()
# 模拟 crawl4ai 未安装
with patch.dict('sys.modules', {'crawl4ai': None}):
with pytest.raises(CrawlError) as exc_info:
await crawler.crawl_page("https://example.com")
# 检查错误消息或只检查异常类型
assert isinstance(exc_info.value, CrawlError)
async def test_crawl_site_auto_strategy(self):
"""测试自动策略选择"""
crawler = SmartCrawler()
# 模拟 _crawl_recursive 方法
crawler._crawl_recursive = AsyncMock(return_value=[])
await crawler.crawl_site(
"https://example.com",
strategy="auto",
max_pages=5,
)
crawler._crawl_recursive.assert_called_once()
async def test_crawl_site_sitemap_strategy(self):
"""测试 sitemap 策略"""
crawler = SmartCrawler()
crawler._crawl_from_sitemap = AsyncMock(return_value=[])
await crawler.crawl_site(
"https://example.com/sitemap.xml",
strategy="sitemap",
max_pages=5,
)
crawler._crawl_from_sitemap.assert_called_once()
# Integration tests (require network and crawl4ai)
@pytest.mark.integration
@pytest.mark.asyncio
class TestCrawlerIntegration:
"""集成测试(需要网络和 crawl4ai)"""
async def test_crawl_example_com(self):
"""测试爬取 example.com"""
crawler = SmartCrawler()
result = await crawler.crawl_page("https://example.com")
assert result.status == "success"
assert result.title
assert result.markdown
assert "Example" in result.title or "Example" in result.markdown
"""登录模块单元测试"""
import pytest
import json
import tempfile
from pathlib import Path
from unittest.mock import Mock, AsyncMock, patch, MagicMock
import sys
sys.path.insert(0, "/Users/lancelin/.openclaw/workspace-crawler/crawl4ai-skill")
from src.login.base import (
LoginBase,
LoginError,
SessionExpiredError,
CookieParseError,
)
from src.login.twitter import TwitterLogin
from src.login.xiaohongshu import XiaohongshuLogin
from src.login.session_manager import (
SessionManager,
get_session_manager,
get_supported_platforms,
register_platform,
)
class TestLoginExceptions:
"""登录异常测试"""
def test_login_error_base_class(self):
"""测试基础异常类"""
error = LoginError("test error")
assert str(error) == "test error"
assert isinstance(error, Exception)
def test_session_expired_error(self):
"""测试 Session 过期异常"""
error = SessionExpiredError("session expired")
assert str(error) == "session expired"
assert isinstance(error, LoginError)
def test_cookie_parse_error(self):
"""测试 Cookie 解析异常"""
error = CookieParseError("invalid cookie")
assert str(error) == "invalid cookie"
assert isinstance(error, LoginError)
class TestTwitterLogin:
"""Twitter 登录测试"""
def test_init_default(self):
"""测试默认初始化"""
with tempfile.TemporaryDirectory() as tmpdir:
login = TwitterLogin(session_dir=Path(tmpdir))
assert login.platform == "twitter"
assert login._get_platform_domain() == "twitter.com"
def test_required_cookies(self):
"""测试必需 Cookie 列表"""
assert "auth_token" in TwitterLogin.REQUIRED_COOKIES
assert "ct0" in TwitterLogin.REQUIRED_COOKIES
def test_has_no_session_initially(self):
"""测试初始无 Session"""
with tempfile.TemporaryDirectory() as tmpdir:
login = TwitterLogin(session_dir=Path(tmpdir))
assert not login.has_saved_session()
def test_clear_nonexistent_session(self):
"""测试清除不存在的 Session"""
with tempfile.TemporaryDirectory() as tmpdir:
login = TwitterLogin(session_dir=Path(tmpdir))
# 不应抛出异常
assert login.clear_session() is True
class TestXiaohongshuLogin:
"""小红书登录测试"""
def test_init_default(self):
"""测试默认初始化"""
with tempfile.TemporaryDirectory() as tmpdir:
login = XiaohongshuLogin(session_dir=Path(tmpdir))
assert login.platform == "xiaohongshu"
assert login._get_platform_domain() == "xiaohongshu.com"
def test_required_cookies(self):
"""测试必需 Cookie 列表"""
assert "web_session" in XiaohongshuLogin.REQUIRED_COOKIES
class TestSessionManager:
"""Session 管理器测试"""
def test_init_creates_directory(self):
"""测试初始化创建目录"""
with tempfile.TemporaryDirectory() as tmpdir:
session_dir = Path(tmpdir) / "sessions"
manager = SessionManager(session_dir=session_dir)
assert session_dir.exists()
def test_get_login_twitter(self):
"""测试获取 Twitter 登录实例"""
with tempfile.TemporaryDirectory() as tmpdir:
manager = SessionManager(session_dir=Path(tmpdir))
login = manager.get_login("twitter")
assert isinstance(login, TwitterLogin)
def test_get_login_xiaohongshu(self):
"""测试获取小红书登录实例"""
with tempfile.TemporaryDirectory() as tmpdir:
manager = SessionManager(session_dir=Path(tmpdir))
login = manager.get_login("xiaohongshu")
assert isinstance(login, XiaohongshuLogin)
def test_get_login_alias(self):
"""测试平台别名"""
with tempfile.TemporaryDirectory() as tmpdir:
manager = SessionManager(session_dir=Path(tmpdir))
# Twitter 别名
x_login = manager.get_login("x")
assert isinstance(x_login, TwitterLogin)
# 小红书别名
xhs_login = manager.get_login("xhs")
assert isinstance(xhs_login, XiaohongshuLogin)
def test_get_login_unsupported(self):
"""测试不支持的平台"""
with tempfile.TemporaryDirectory() as tmpdir:
manager = SessionManager(session_dir=Path(tmpdir))
with pytest.raises(LoginError) as exc_info:
manager.get_login("unsupported_platform")
assert "不支持的平台" in str(exc_info.value)
def test_get_supported_platforms(self):
"""测试获取支持的平台列表"""
platforms = get_supported_platforms()
assert "twitter" in platforms
assert "xiaohongshu" in platforms
def test_get_all_sessions_status(self):
"""测试获取所有 Session 状态"""
with tempfile.TemporaryDirectory() as tmpdir:
manager = SessionManager(session_dir=Path(tmpdir))
status = manager.get_all_sessions_status()
# 应该包含所有支持的平台
for platform in get_supported_platforms():
assert platform in status
assert "has_session" in status[platform]
def test_is_logged_in_false(self):
"""测试未登录状态"""
with tempfile.TemporaryDirectory() as tmpdir:
manager = SessionManager(session_dir=Path(tmpdir))
assert not manager.is_logged_in("twitter")
def test_clear_all_sessions(self):
"""测试清除所有 Session"""
with tempfile.TemporaryDirectory() as tmpdir:
manager = SessionManager(session_dir=Path(tmpdir))
results = manager.clear_all_sessions()
# 所有平台都应该返回 True(无 Session 也算成功)
for platform, success in results.items():
assert success is True
class TestCookieParsing:
"""Cookie 解析测试"""
@pytest.mark.asyncio
async def test_parse_standard_cookies(self):
"""测试解析标准格式 Cookie"""
with tempfile.TemporaryDirectory() as tmpdir:
login = TwitterLogin(session_dir=Path(tmpdir))
# 模拟 context
mock_context = AsyncMock()
mock_context.add_cookies = AsyncMock()
cookies_str = "auth_token=abc123; ct0=xyz789"
await login._import_standard_cookies(cookies_str, mock_context)
# 验证 add_cookies 被调用
mock_context.add_cookies.assert_called_once()
cookies = mock_context.add_cookies.call_args[0][0]
assert len(cookies) == 2
cookie_dict = {c["name"]: c["value"] for c in cookies}
assert cookie_dict["auth_token"] == "abc123"
assert cookie_dict["ct0"] == "xyz789"
@pytest.mark.asyncio
async def test_parse_json_cookies(self):
"""测试解析 JSON 格式 Cookie"""
with tempfile.TemporaryDirectory() as tmpdir:
login = TwitterLogin(session_dir=Path(tmpdir))
mock_context = AsyncMock()
mock_context.add_cookies = AsyncMock()
cookies_json = json.dumps([
{"name": "auth_token", "value": "abc123"},
{"name": "ct0", "value": "xyz789"},
])
await login._import_json_cookies(cookies_json, mock_context)
mock_context.add_cookies.assert_called_once()
cookies = mock_context.add_cookies.call_args[0][0]
assert len(cookies) == 2
cookie_dict = {c["name"]: c["value"] for c in cookies}
assert cookie_dict["auth_token"] == "abc123"
@pytest.mark.asyncio
async def test_parse_empty_cookies_raises_error(self):
"""测试空 Cookie 抛出错误"""
with tempfile.TemporaryDirectory() as tmpdir:
login = TwitterLogin(session_dir=Path(tmpdir))
mock_context = AsyncMock()
with pytest.raises(CookieParseError) as exc_info:
await login.import_cookies("", mock_context)
assert "为空" in str(exc_info.value)
@pytest.mark.asyncio
async def test_parse_invalid_json_raises_error(self):
"""测试无效 JSON 抛出错误"""
with tempfile.TemporaryDirectory() as tmpdir:
login = TwitterLogin(session_dir=Path(tmpdir))
mock_context = AsyncMock()
with pytest.raises(CookieParseError) as exc_info:
await login._import_json_cookies("[invalid json", mock_context)
assert "JSON 解析失败" in str(exc_info.value)
class TestSessionPersistence:
"""Session 持久化测试"""
@pytest.mark.asyncio
async def test_save_and_load_session(self):
"""测试保存和加载 Session"""
with tempfile.TemporaryDirectory() as tmpdir:
login = TwitterLogin(session_dir=Path(tmpdir))
# 模拟 context
mock_context = AsyncMock()
mock_context.storage_state = AsyncMock(return_value={
"cookies": [
{"name": "auth_token", "value": "abc123", "domain": ".twitter.com"},
{"name": "ct0", "value": "xyz789", "domain": ".twitter.com"},
],
"origins": [],
})
# 保存 Session
await login.save_session(mock_context)
assert login.has_saved_session()
# 获取 Session 信息
info = login.get_session_info()
assert info is not None
assert info["platform"] == "twitter"
assert info["cookie_count"] == 2
assert "auth_token" in info["cookie_names"]
# 加载 Session
mock_context_2 = AsyncMock()
mock_context_2.add_cookies = AsyncMock()
success = await login.load_session(mock_context_2)
assert success is True
mock_context_2.add_cookies.assert_called_once()
@pytest.mark.asyncio
async def test_load_nonexistent_session(self):
"""测试加载不存在的 Session"""
with tempfile.TemporaryDirectory() as tmpdir:
login = TwitterLogin(session_dir=Path(tmpdir))
mock_context = AsyncMock()
success = await login.load_session(mock_context)
assert success is False
def test_get_session_info_no_session(self):
"""测试获取不存在的 Session 信息"""
with tempfile.TemporaryDirectory() as tmpdir:
login = TwitterLogin(session_dir=Path(tmpdir))
info = login.get_session_info()
assert info is None
class TestBrowserStealth:
"""浏览器 Stealth 测试"""
def test_user_agents_list(self):
"""测试 User-Agent 列表"""
from src.browser.stealth import USER_AGENTS, get_random_user_agent
assert len(USER_AGENTS) > 0
ua = get_random_user_agent()
assert ua in USER_AGENTS
assert "Mozilla" in ua
@pytest.mark.asyncio
async def test_apply_stealth_without_library(self):
"""测试无 playwright-stealth 时的处理"""
from src.browser.stealth import apply_stealth
mock_page = AsyncMock()
# 即使没有 playwright-stealth 也不应抛出异常
with patch.dict('sys.modules', {'playwright_stealth': None}):
await apply_stealth(mock_page) # 不应抛出异常
"""解析模块单元测试"""
import pytest
import sys
sys.path.insert(0, "/Users/lancelin/.openclaw/workspace-crawler/crawl4ai-skill")
from src.parser import ContentParser
class TestContentParser:
"""ContentParser 测试"""
def test_format_markdown_with_header(self):
"""测试带头部的格式化"""
parser = ContentParser()
content = "# Hello\n\nThis is content."
metadata = {
"title": "Test Page",
"url": "https://example.com",
"timestamp": "2026-03-10T00:00:00Z",
"format": "fit_markdown",
}
result = parser.format_markdown(content, metadata)
assert "# Test Page" in result
assert "https://example.com" in result
assert "Hello" in result
assert "This is content." in result
def test_format_markdown_without_header(self):
"""测试不带头部的格式化"""
parser = ContentParser()
content = "# Hello\n\nThis is content."
metadata = {"title": "Test"}
result = parser.format_markdown(content, metadata, include_header=False)
assert result == content
def test_add_citations_with_links(self):
"""测试添加引用"""
parser = ContentParser()
content = "Some content."
links = ["https://example.com/1", "https://example.com/2"]
result = parser.add_citations(content, links)
assert "## References" in result
assert "[1]: https://example.com/1" in result
assert "[2]: https://example.com/2" in result
def test_add_citations_empty_links(self):
"""测试空链接列表"""
parser = ContentParser()
content = "Some content."
result = parser.add_citations(content, [])
assert result == content
def test_add_citations_filter_empty_links(self):
"""测试过滤空链接"""
parser = ContentParser()
content = "Some content."
links = ["https://example.com", "", " ", "https://test.com"]
result = parser.add_citations(content, links)
assert "[1]: https://example.com" in result
assert "[2]: https://test.com" in result
def test_clean_markdown_removes_extra_newlines(self):
"""测试清理多余空行"""
parser = ContentParser()
content = "Line 1\n\n\n\n\n\nLine 2"
result = parser.clean_markdown(content)
# 应该最多保留 3 个连续换行
assert "\n\n\n\n" not in result
assert "Line 1" in result
assert "Line 2" in result
def test_clean_markdown_strips_trailing_whitespace(self):
"""测试去除行尾空白"""
parser = ContentParser()
content = "Line with spaces \nLine 2 "
result = parser.clean_markdown(content)
assert " \n" not in result
def test_clean_markdown_empty_content(self):
"""测试清理空内容"""
parser = ContentParser()
assert parser.clean_markdown("") == ""
assert parser.clean_markdown(None) == ""
def test_extract_title_from_markdown(self):
"""测试从 Markdown 提取标题"""
parser = ContentParser()
content = "Some intro\n\n# Main Title\n\nContent..."
title = parser.extract_title_from_markdown(content)
assert title == "Main Title"
def test_extract_title_no_title(self):
"""测试无标题情况"""
parser = ContentParser()
content = "Just some content without h1"
title = parser.extract_title_from_markdown(content)
assert title is None
def test_extract_title_empty_content(self):
"""测试空内容"""
parser = ContentParser()
assert parser.extract_title_from_markdown("") is None
assert parser.extract_title_from_markdown(None) is None
def test_truncate_content_short(self):
"""测试短内容不截断"""
parser = ContentParser()
content = "Short content"
result = parser.truncate_content(content, max_length=100)
assert result == content
def test_truncate_content_long(self):
"""测试长内容截断"""
parser = ContentParser()
content = "A" * 200
result = parser.truncate_content(content, max_length=100)
assert len(result) < 200
assert "... (内容已截断)" in result
def test_truncate_content_at_paragraph(self):
"""测试在段落边界截断"""
parser = ContentParser()
content = "First paragraph.\n\nSecond paragraph that is long enough."
# 设置截断点在第二段中间
result = parser.truncate_content(content, max_length=40)
# 应该在段落边界截断
assert "First paragraph." in result
def test_merge_results(self):
"""测试合并多个结果"""
parser = ContentParser()
results = [
{"title": "Page 1", "url": "https://example.com/1", "markdown": "Content 1"},
{"title": "Page 2", "url": "https://example.com/2", "markdown": "Content 2"},
]
merged = parser.merge_results(results)
assert "## Page 1" in merged
assert "## Page 2" in merged
assert "Content 1" in merged
assert "Content 2" in merged
assert "---" in merged # 分隔符
def test_merge_results_empty(self):
"""测试合并空结果"""
parser = ContentParser()
assert parser.merge_results([]) == ""
"""搜索模块单元测试"""
import pytest
from unittest.mock import Mock, patch, MagicMock
import sys
sys.path.insert(0, "/Users/lancelin/.openclaw/workspace-crawler/crawl4ai-skill")
from src.search import (
DuckDuckGoSearcher,
SearchResult,
SearchError,
EmptyQueryError,
SearchNetworkError,
RateLimitError,
)
class TestSearchResult:
"""SearchResult 数据类测试"""
def test_create_search_result(self):
"""测试创建搜索结果"""
result = SearchResult(
title="Test Title",
url="https://example.com",
snippet="Test snippet",
timestamp="2026-03-10T00:00:00+00:00",
)
assert result.title == "Test Title"
assert result.url == "https://example.com"
assert result.snippet == "Test snippet"
assert result.timestamp == "2026-03-10T00:00:00+00:00"
def test_to_dict(self):
"""测试转换为字典"""
result = SearchResult(
title="Test",
url="https://test.com",
snippet="Snippet",
timestamp="2026-03-10T00:00:00+00:00",
)
d = result.to_dict()
assert d["title"] == "Test"
assert d["url"] == "https://test.com"
assert d["snippet"] == "Snippet"
class TestDuckDuckGoSearcher:
"""DuckDuckGoSearcher 测试"""
def test_init_default_timeout(self):
"""测试默认超时设置"""
searcher = DuckDuckGoSearcher()
assert searcher.timeout == 10
def test_init_custom_timeout(self):
"""测试自定义超时"""
searcher = DuckDuckGoSearcher(timeout=30)
assert searcher.timeout == 30
def test_search_empty_query_raises_error(self):
"""测试空查询抛出错误"""
searcher = DuckDuckGoSearcher()
with pytest.raises(EmptyQueryError):
searcher.search("")
def test_search_whitespace_query_raises_error(self):
"""测试空白查询抛出错误"""
searcher = DuckDuckGoSearcher()
with pytest.raises(EmptyQueryError):
searcher.search(" ")
def test_search_none_query_raises_error(self):
"""测试 None 查询抛出错误"""
searcher = DuckDuckGoSearcher()
with pytest.raises(EmptyQueryError):
searcher.search(None)
@patch.object(DuckDuckGoSearcher, "ddgs", new_callable=lambda: MagicMock())
def test_search_success(self, mock_ddgs):
"""测试成功搜索"""
# 模拟搜索结果
mock_ddgs.text.return_value = [
{
"title": "Python Tutorial",
"href": "https://python.org/tutorial",
"body": "Learn Python programming",
},
{
"title": "Python Docs",
"href": "https://docs.python.org",
"body": "Official Python documentation",
},
]
searcher = DuckDuckGoSearcher()
searcher._ddgs = mock_ddgs
results = searcher.search("python", num_results=2)
assert len(results) == 2
assert results[0].title == "Python Tutorial"
assert results[0].url == "https://python.org/tutorial"
assert results[1].title == "Python Docs"
@patch.object(DuckDuckGoSearcher, "ddgs", new_callable=lambda: MagicMock())
def test_search_num_results_limit(self, mock_ddgs):
"""测试结果数量限制"""
mock_ddgs.text.return_value = []
searcher = DuckDuckGoSearcher()
searcher._ddgs = mock_ddgs
# 测试最小值限制
searcher.search("test", num_results=0)
mock_ddgs.text.assert_called_with("test", max_results=1, safesearch="moderate")
# 测试最大值限制
searcher.search("test", num_results=200)
mock_ddgs.text.assert_called_with(
"test", max_results=100, safesearch="moderate"
)
@patch.object(DuckDuckGoSearcher, "ddgs", new_callable=lambda: MagicMock())
def test_search_strips_query(self, mock_ddgs):
"""测试查询去除空白"""
mock_ddgs.text.return_value = []
searcher = DuckDuckGoSearcher()
searcher._ddgs = mock_ddgs
searcher.search(" python ", num_results=5)
mock_ddgs.text.assert_called_with(
"python", max_results=5, safesearch="moderate"
)
def test_search_news_empty_query_raises_error(self):
"""测试新闻搜索空查询抛出错误"""
searcher = DuckDuckGoSearcher()
with pytest.raises(EmptyQueryError):
searcher.search_news("")
@patch.object(DuckDuckGoSearcher, "ddgs", new_callable=lambda: MagicMock())
def test_search_news_success(self, mock_ddgs):
"""测试新闻搜索成功"""
mock_ddgs.news.return_value = [
{
"title": "Breaking News",
"url": "https://news.com/article",
"body": "News content",
"date": "2026-03-10",
}
]
searcher = DuckDuckGoSearcher()
searcher._ddgs = mock_ddgs
results = searcher.search_news("technology", num_results=1)
assert len(results) == 1
assert results[0].title == "Breaking News"
assert results[0].url == "https://news.com/article"
class TestSearchExceptions:
"""搜索异常测试"""
def test_search_error_base_class(self):
"""测试基础异常类"""
error = SearchError("test error")
assert str(error) == "test error"
assert isinstance(error, Exception)
def test_empty_query_error(self):
"""测试空查询异常"""
error = EmptyQueryError("empty query")
assert isinstance(error, SearchError)
def test_network_error(self):
"""测试网络异常"""
error = SearchNetworkError("network failed")
assert isinstance(error, SearchError)
def test_rate_limit_error(self):
"""测试限流异常"""
error = RateLimitError("rate limited")
assert str(error) == "rate limited"
assert isinstance(error, SearchError)
# Integration test (requires network) - marked for optional execution
@pytest.mark.integration
class TestSearchIntegration:
"""集成测试(需要网络)"""
def test_real_search(self):
"""测试真实搜索 - 需要网络连接"""
searcher = DuckDuckGoSearcher()
results = searcher.search("python programming language", num_results=3)
assert len(results) > 0
assert all(r.url for r in results)
assert all(r.title for r in results)
Related skills
How it compares
Use crawl4ai-skill for keyless agent-friendly markdown crawls; use a paid search API skill when rate limits or SERP fidelity require commercial indexes.
FAQ
Does crawl4ai-skill require an API key?
crawl4ai-skill operates without an API key for DuckDuckGo search and site crawling. The skill requires the crawl4ai binary and outputs LLM-optimized markdown suited for agent and RAG pipelines.
What version and license is crawl4ai-skill?
crawl4ai-skill is version 1.1.0 under the MIT-0 license, published as the crawl4ai-skill PyPI package with repository at github.com/lancelin111/crawl4ai-skill.