Skill: nalin3166-collab/weixin-reader (version 1.1.0)

--- name: weixin-reader description: 提取微信公众号文章内容，支持任意公众号文章链接的内容抓取和结构化输出。 triggers: - "微信公众号" - "mp.weixin.qq.com" - "微信文章" allowed-tools: ["Bash", "Read", "Write"] --- # 微信公众号阅读器 提取微信公众号文章内容，支持任意公众号文章链接的内容抓取。 ## 使用方法 直接发送微信公众号文章链接即可： - https://mp.weixin.qq.com/s/xxxx ## 功能 - 自动渲染 JavaScript 动态内容 - 提取标题、作者、公众号名称、发布时间、正文内容 - 输出干净的 Markdown 格式 - **SSRF 防护**：DNS 解析验证，防止访问内网地址 ## 依赖 - Python 3.8+ - Playwright - playwright-stealth（反爬增强，可选） - dnspython（DNS 解析安全校验） 安装依赖： ```bash cd ~/.openclaw/workspace/skills/weixin-reader pip install -r requirements.txt playwright install chromium ``` ## 安全说明 ### SSRF 防护 - 禁止访问 localhost、127.0.0.1 等本地地址 - 禁止访问 10.x.x.x、172.16-31.x.x、192.168.x.x 等私有网段 - **DNS 解析检查**：验证域名解析后的 IP 不是内网地址（防止 DNS 重绑定攻击） ### 使用限制 - 仅支持提取公开文章内容 - 请遵守目标网站的服务条款 - 不建议用于高频批量抓取 ## 文件说明 - `extract.py` - 主提取脚本（推荐，标准模式） - `extract_stealth.py` - 反爬增强模式（**可选**，使用 playwright-stealth，可能违反某些网站 ToS） - `extract_generic.py` - 通用网页提取 ## 更新日志 ### v1.1.0 - **结构化数据输出**：返回 JSON 格式，包含 metadata、content、stats 三个部分 - **新增统计信息**：字数、段落数、图片数、预估阅读时间 - **增强元数据**：提取时间戳、文章描述、HTML 内容 - **图片信息丰富**：返回图片 URL 和 alt 文本 ### v1.0.1 - 增强 SSRF 防护：添加 DNS 解析验证 - 补充完整依赖：playwright-stealth, dnspython - 完善安全说明文档

path: debug.py
content: #!/usr/bin/env python3
"""
调试版提取器 - 保存截图和 HTML 用于分析
"""

import sys
import json
from playwright.sync_api import sync_playwright
from bs4 import BeautifulSoup


def debug_page(url, output_dir='/tmp'):
    """调试页面，保存截图和 HTML"""
    
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context(
            user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
            viewport={'width': 1280, 'height': 800}
        )
        page = context.new_page()
        
        try:
            print(f"正在访问: {url}")
            page.goto(url, wait_until='domcontentloaded', timeout=30000)
            page.wait_for_timeout(5000)  # 等待 5 秒
            
            # 截图
            screenshot_path = f"{output_dir}/debug_zhihu.png"
            page.screenshot(path=screenshot_path, full_page=True)
            print(f"截图已保存: {screenshot_path}")
            
            # 保存 HTML
            html = page.content()
            html_path = f"{output_dir}/debug_zhihu.html"
            with open(html_path, 'w', encoding='utf-8') as f:
                f.write(html)
            print(f"HTML 已保存: {html_path}")
            
            # 简单分析
            soup = BeautifulSoup(html, 'html.parser')
            title = soup.find('title')
            print(f"页面标题: {title.get_text() if title else '无'}")
            
            # 查找可能的内容区域
            selectors = ['article', 'main', '.Post-content', '.RichContent', '[class*="content"]']
            for sel in selectors:
                elems = soup.select(sel)
                print(f"选择器 '{sel}': 找到 {len(elems)} 个元素")
            
            browser.close()
            return {'success': True, 'screenshot': screenshot_path, 'html': html_path}
            
        except Exception as e:
            print(f"错误: {e}")
            browser.close()
            return {'success': False, 'error': str(e)}


if __name__ == '__main__':
    url = sys.argv[1] if len(sys.argv) > 1 else 'https://zhuanlan.zhihu.com/p/2010717224810353568'
    result = debug_page(url)
    print(json.dumps(result, indent=2))


path: extract.py
content: #!/usr/bin/env python3
"""
微信公众号文章提取器
使用 Playwright 渲染页面并提取内容
"""

import sys
import json
import re
import socket
from urllib.parse import urlparse
from playwright.sync_api import sync_playwright
from bs4 import BeautifulSoup


def validate_url(url):
    """验证 URL 安全性，防止 SSRF 和非法访问
    
    安全检查包括：
    1. 协议检查 - 只允许 http/https
    2. 字面主机名检查 - 禁止明显的内网地址
    3. DNS 解析检查 - 确保解析后的 IP 不是内网地址（防止 DNS 重绑定攻击）
    """
    # 检查是否为空
    if not url or not isinstance(url, str):
        return False, "URL 不能为空"
    
    # 去除空白字符
    url = url.strip()
    
    # 只允许 http/https 协议
    if not url.startswith(('http://', 'https://')):
        return False, "仅支持 HTTP/HTTPS 协议"
    
    try:
        parsed = urlparse(url)
        hostname = parsed.hostname or ""
        
        if not hostname:
            return False, "无法解析主机名"
        
        # 禁止访问内网地址（字面检查）
        blocked_hosts = [
            'localhost', '127.0.0.1', '0.0.0.0',
            '::1', '[::1]',  # IPv6 localhost
        ]
        
        for blocked in blocked_hosts:
            if hostname == blocked or hostname.startswith(blocked):
                return False, f"禁止访问内网地址: {hostname}"
        
        # 禁止私有 IP 前缀（字面检查）
        blocked_prefixes = [
            '10.', '172.16.', '172.17.', '172.18.', '172.19.',
            '172.20.', '172.21.', '172.22.', '172.23.', '172.24.',
            '172.25.', '172.26.', '172.27.', '172.28.', '172.29.',
            '172.30.', '172.31.', '192.168.'
        ]
        
        for prefix in blocked_prefixes:
            if hostname.startswith(prefix):
                return False, f"禁止访问内网地址: {hostname}"
        
        # DNS 解析检查 - 防止 DNS 重绑定攻击
        try:
            resolved_ip = socket.getaddrinfo(hostname, None)[0][4][0]
            
            # 检查解析后的 IP 是否是内网地址
            if resolved_ip.startswith(('10.', '172.16.', '172.17.', '172.18.', '172.19.',
                                       '172.20.', '172.21.', '172.22.', '172.23.', '172.24.',
                                       '172.25.', '172.26.', '172.27.', '172.28.', '172.29.',
                                       '172.30.', '172.31.', '192.168.', '127.', '0.', '::1')):
                return False, f"域名解析到内网地址，禁止访问: {hostname} -> {resolved_ip}"
        except socket.gaierror:
            # DNS 解析失败，可能是内网域名或无效域名
            return False, f"无法解析域名: {hostname}"
        
        return True, None
        
    except Exception as e:
        return False, f"URL 解析错误: {str(e)}"


def clean_text(text):
    """清理文本内容"""
    if not text:
        return ""
    # 移除多余空白
    text = re.sub(r'\s+', ' ', text)
    return text.strip()


def extract_article(url):
    """提取微信公众号文章内容
    
    Returns:
        dict: 结构化数据，包含 metadata、content、stats
    """
    
    with sync_playwright() as p:
        # 启动浏览器（无头模式）
        browser = p.chromium.launch(headless=True)
        context = browser.new_context(
            user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
        )
        page = context.new_page()
        
        try:
            # 访问页面并等待加载
            page.goto(url, wait_until='networkidle', timeout=30000)
            
            # 等待内容加载
            page.wait_for_selector('#js_content, .rich_media_content', timeout=10000)
            
            # 获取页面 HTML
            html = page.content()
            soup = BeautifulSoup(html, 'html.parser')
            
            # ========== 提取元数据 ==========
            metadata = {}
            
            # 标题
            title = ""
            title_elem = soup.select_one('#activity_name, .rich_media_title, h1')
            if title_elem:
                title = clean_text(title_elem.get_text())
            metadata['title'] = title
            
            # 公众号名称
            account = ""
            account_elem = soup.select_one('#js_name, .profile_nickname, .rich_media_meta_nickname')
            if account_elem:
                account = clean_text(account_elem.get_text())
            metadata['account'] = account
            
            # 作者
            author = ""
            author_elem = soup.select_one('#js_author_name, .rich_media_meta_text')
            if author_elem:
                author = clean_text(author_elem.get_text())
            metadata['author'] = author
            
            # 发布时间
            publish_time = ""
            time_elem = soup.select_one('#publish_time, .rich_media_meta_text em')
            if time_elem:
                publish_time = clean_text(time_elem.get_text())
            metadata['publish_time'] = publish_time
            
            # 文章描述/摘要（如果有）
            description = ""
            meta_desc = soup.find('meta', {'name': 'description'})
            if meta_desc:
                description = meta_desc.get('content', '')
            metadata['description'] = description
            
            # 原文链接
            metadata['source_url'] = url
            
            # 提取时间戳
            metadata['extracted_at'] = __import__('datetime').datetime.now().isoformat()
            
            # ========== 提取正文内容 ==========
            content = ""
            content_html = ""
            content_elem = soup.select_one('#js_content, .rich_media_content')
            
            if content_elem:
                # 保存原始 HTML
                content_html = str(content_elem)
                
                # 移除脚本和样式
                for script in content_elem(['script', 'style', 'iframe']):
                    script.decompose()
                
                # 提取文本，保留段落结构
                paragraphs = []
                for p in content_elem.find_all(['p', 'section']):
                    text = clean_text(p.get_text())
                    if text and len(text) > 5:  # 过滤太短的片段
                        paragraphs.append(text)
                
                content = '\n\n'.join(paragraphs)
            
            # ========== 提取图片 ==========
            images = []
            for img in soup.select('#js_content img, .rich_media_content img'):
                src = img.get('data-src') or img.get('src')
                if src and src.startswith('http'):
                    images.append({
                        'url': src,
                        'alt': img.get('alt', '')
                    })
            
            # ========== 统计信息 ==========
            stats = {
                'content_length': len(content),
                'content_chars': len(content.replace('\n', '').replace(' ', '')),
                'paragraph_count': len([p for p in content.split('\n\n') if p.strip()]),
                'image_count': len(images),
                'has_title': bool(title),
                'has_author': bool(author),
                'has_account': bool(account)
            }
            
            # 估算阅读时间（按 300 字/分钟）
            stats['read_time_minutes'] = max(1, round(stats['content_chars'] / 300))
            
            browser.close()
            
            # ========== 返回结构化数据 ==========
            return {
                'success': True,
                'version': '1.1.0',
                'metadata': metadata,
                'content': {
                    'text': content,
                    'html': content_html[:5000] if content_html else ''  # 限制 HTML 大小
                },
                'images': images[:20],  # 最多返回 20 张图片
                'stats': stats
            }
            
        except Exception as e:
            browser.close()
            return {
                'success': False,
                'version': '1.1.0',
                'error': str(e),
                'url': url
            }


if __name__ == '__main__':
    if len(sys.argv) < 2:
        print(json.dumps({
            'success': False,
            'error': '请提供微信公众号文章链接'
        }, ensure_ascii=False))
        sys.exit(1)
    
    url = sys.argv[1]
    
    # 验证 URL 安全性
    is_valid, error_msg = validate_url(url)
    if not is_valid:
        print(json.dumps({
            'success': False,
            'error': f'URL 验证失败: {error_msg}'
        }, ensure_ascii=False))
        sys.exit(1)
    
    result = extract_article(url)
    print(json.dumps(result, ensure_ascii=False, indent=2))


path: extract_generic.py
content: #!/usr/bin/env python3
"""
通用网页文章提取器
支持：微信公众号、知乎专栏、简书、CSDN 等
"""

import sys
import json
import re
from playwright.sync_api import sync_playwright
from bs4 import BeautifulSoup


def clean_text(text):
    """清理文本内容"""
    if not text:
        return ""
    text = re.sub(r'\s+', ' ', text)
    return text.strip()


def extract_zhihu(soup):
    """提取知乎文章"""
    # 知乎专栏文章选择器
    title = ""
    title_elem = soup.select_one('h1, .Post-Title, [data-zop-title]')
    if title_elem:
        title = clean_text(title_elem.get_text())
    
    author = ""
    author_elem = soup.select_one('.AuthorInfo-name, .UserLink-link')
    if author_elem:
        author = clean_text(author_elem.get_text())
    
    content = ""
    content_elem = soup.select_one('.Post-RichTextContainer, .RichContent-inner')
    if content_elem:
        for script in content_elem(['script', 'style', 'iframe']):
            script.decompose()
        paragraphs = []
        for p in content_elem.find_all(['p', 'h2', 'h3', 'li']):
            text = clean_text(p.get_text())
            if text and len(text) > 5:
                paragraphs.append(text)
        content = '\n\n'.join(paragraphs)
    
    return {'title': title, 'author': author, 'content': content}


def extract_weixin(soup):
    """提取微信公众号文章"""
    title = ""
    title_elem = soup.select_one('#activity_name, .rich_media_title, h1')
    if title_elem:
        title = clean_text(title_elem.get_text())
    
    account = ""
    account_elem = soup.select_one('#js_name, .profile_nickname')
    if account_elem:
        account = clean_text(account_elem.get_text())
    
    author = ""
    author_elem = soup.select_one('#js_author_name')
    if author_elem:
        author = clean_text(author_elem.get_text())
    
    content = ""
    content_elem = soup.select_one('#js_content, .rich_media_content')
    if content_elem:
        for script in content_elem(['script', 'style', 'iframe']):
            script.decompose()
        paragraphs = []
        for p in content_elem.find_all(['p', 'section']):
            text = clean_text(p.get_text())
            if text and len(text) > 5:
                paragraphs.append(text)
        content = '\n\n'.join(paragraphs)
    
    return {'title': title, 'account': account, 'author': author, 'content': content}


def extract_generic(soup):
    """通用提取策略"""
    title = ""
    # 尝试多种标题选择器
    for selector in ['h1', 'article h1', '.article-title', '.post-title', '[class*="title"]']:
        elem = soup.select_one(selector)
        if elem:
            title = clean_text(elem.get_text())
            if len(title) > 5:
                break
    
    # 找正文内容
    content = ""
    for selector in ['article', 'main', '[class*="content"]', '[class*="article"]', '.post']:
        elem = soup.select_one(selector)
        if elem:
            for script in elem(['script', 'style', 'iframe', 'nav', 'header', 'footer']):
                script.decompose()
            paragraphs = []
            for p in elem.find_all(['p', 'h2', 'h3', 'li']):
                text = clean_text(p.get_text())
                if text and len(text) > 10:
                    paragraphs.append(text)
            if len(paragraphs) > 3:
                content = '\n\n'.join(paragraphs)
                break
    
    return {'title': title, 'content': content}


def extract_article(url):
    """提取文章内容"""
    
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context(
            user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
        )
        page = context.new_page()
        
        try:
            page.goto(url, wait_until='networkidle', timeout=30000)
            page.wait_for_timeout(3000)  # 等待 JS 渲染
            
            html = page.content()
            soup = BeautifulSoup(html, 'html.parser')
            
            # 根据域名选择提取策略
            if 'zhuanlan.zhihu.com' in url or 'zhihu.com' in url:
                data = extract_zhihu(soup)
                platform = '知乎'
            elif 'mp.weixin.qq.com' in url:
                data = extract_weixin(soup)
                platform = '微信公众号'
            else:
                data = extract_generic(soup)
                platform = '通用'
            
            browser.close()
            
            return {
                'success': True,
                'url': url,
                'platform': platform,
                **data,
                'content_length': len(data.get('content', ''))
            }
            
        except Exception as e:
            browser.close()
            return {
                'success': False,
                'error': str(e),
                'url': url
            }


if __name__ == '__main__':
    if len(sys.argv) < 2:
        print(json.dumps({'success': False, 'error': '请提供文章链接'}, ensure_ascii=False))
        sys.exit(1)
    
    url = sys.argv[1]
    result = extract_article(url)
    print(json.dumps(result, ensure_ascii=False, indent=2))


path: extract_notion.py
content: #!/usr/bin/env python3
"""
提取 Notion 页面内容
"""

import sys
import json
import re
from playwright.sync_api import sync_playwright
from bs4 import BeautifulSoup


def clean_text(text):
    """清理文本内容"""
    if not text:
        return ""
    text = re.sub(r'\s+', ' ', text)
    return text.strip()


def extract_notion(url):
    """提取 Notion 页面内容"""
    
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context(
            user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
            viewport={'width': 1280, 'height': 800}
        )
        page = context.new_page()
        
        try:
            print(f"正在访问: {url}")
            page.goto(url, wait_until='domcontentloaded', timeout=60000)
            page.wait_for_timeout(8000)  # Notion 加载较慢，等待 8 秒
            
            # 获取页面标题
            title = page.title()
            print(f"页面标题: {title}")
            
            # 获取 HTML
            html = page.content()
            soup = BeautifulSoup(html, 'html.parser')
            
            # 提取标题（Notion 页面标题通常在 h1）
            article_title = ""
            h1_elem = soup.find('h1')
            if h1_elem:
                article_title = clean_text(h1_elem.get_text())
            
            # 提取正文内容
            content = []
            
            # Notion 的内容通常在特定结构中
            # 尝试多种选择器
            content_selectors = [
                '[data-block-id]',
                '.notion-page-content',
                'article',
                'main',
                '[class*="content"]'
            ]
            
            for selector in content_selectors:
                elems = soup.select(selector)
                if elems:
                    for elem in elems:
                        # 获取所有文本块
                        for block in elem.find_all(['p', 'h1', 'h2', 'h3', 'li', 'blockquote']):
                            text = clean_text(block.get_text())
                            if text and len(text) > 5:
                                content.append(text)
                    if len(content) > 5:  # 如果找到了足够的内容，就停止
                        break
            
            # 如果没有找到内容，尝试通用方法
            if not content:
                body = soup.find('body')
                if body:
                    for elem in body.find_all(['p', 'h1', 'h2', 'h3', 'li']):
                        text = clean_text(elem.get_text())
                        if text and len(text) > 10:
                            content.append(text)
            
            # 去重并保持顺序
            seen = set()
            unique_content = []
            for text in content:
                if text not in seen and text != article_title:
                    seen.add(text)
                    unique_content.append(text)
            
            browser.close()
            
            return {
                'success': True,
                'url': url,
                'page_title': title,
                'article_title': article_title,
                'content': '\n\n'.join(unique_content[:100]),  # 限制段落数
                'paragraph_count': len(unique_content)
            }
            
        except Exception as e:
            print(f"错误: {e}")
            browser.close()
            return {'success': False, 'error': str(e), 'url': url}


if __name__ == '__main__':
    url = sys.argv[1] if len(sys.argv) > 1 else 'https://www.notion.so/Playwright-CLI-Claude-Code-1bd25b1f28234a45b69b3f5d75a595c1'
    result = extract_notion(url)
    print(json.dumps(result, ensure_ascii=False, indent=2))


path: extract_stealth.py
content: #!/usr/bin/env python3
"""
使用 Playwright Stealth 模式的通用文章提取器
尝试绕过知乎等平台的反爬检测
"""

import sys
import json
import re
from playwright.sync_api import sync_playwright
from playwright_stealth.stealth import Stealth
from bs4 import BeautifulSoup


def clean_text(text):
    """清理文本内容"""
    if not text:
        return ""
    text = re.sub(r'\s+', ' ', text)
    return text.strip()


def extract_zhihu(soup):
    """提取知乎文章"""
    title = ""
    for selector in ['h1.Post-Title', 'h1', '[data-zop-title]', '.Title']:
        elem = soup.select_one(selector)
        if elem:
            title = clean_text(elem.get_text())
            if title:
                break
    
    author = ""
    for selector in ['.AuthorInfo-name', '.UserLink-link', '.author']:
        elem = soup.select_one(selector)
        if elem:
            author = clean_text(elem.get_text())
            if author:
                break
    
    content = ""
    for selector in ['.Post-RichTextContainer', '.RichContent-inner', 'article', '.content']:
        elem = soup.select_one(selector)
        if elem:
            for script in elem(['script', 'style', 'iframe']):
                script.decompose()
            paragraphs = []
            for p in elem.find_all(['p', 'h2', 'h3', 'li', 'blockquote']):
                text = clean_text(p.get_text())
                if text and len(text) > 5:
                    paragraphs.append(text)
            if paragraphs:
                content = '\n\n'.join(paragraphs)
                break
    
    return {'title': title, 'author': author, 'content': content}


def extract_with_stealth(url):
    """使用 stealth 模式提取文章"""
    
    with sync_playwright() as p:
        # 启动浏览器（非无头模式更容易绕过检测）
        browser = p.chromium.launch(
            headless=True,  # 可以尝试改为 False 看是否有区别
            args=[
                '--disable-blink-features=AutomationControlled',
                '--disable-web-security',
                '--disable-features=IsolateOrigins,site-per-process',
            ]
        )
        
        # 创建上下文，模拟真实浏览器
        context = browser.new_context(
            user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
            viewport={'width': 1920, 'height': 1080},
            locale='zh-CN',
            timezone_id='Asia/Shanghai',
            permissions=['notifications'],
        )
        
        page = context.new_page()
        
        # 应用 stealth 模式
        Stealth().apply_stealth_sync(page)
        
        try:
            print(f"正在访问: {url}")
            
            # 先访问知乎首页建立会话
            page.goto('https://www.zhihu.com', wait_until='domcontentloaded', timeout=15000)
            page.wait_for_timeout(2000)
            
            # 再访问目标文章
            page.goto(url, wait_until='networkidle', timeout=30000)
            page.wait_for_timeout(5000)  # 等待内容渲染
            
            # 获取页面信息
            title = page.title()
            print(f"页面标题: {title}")
            
            html = page.content()
            soup = BeautifulSoup(html, 'html.parser')
            
            # 检查是否被拦截
            error_elem = soup.select_one('pre')
            if error_elem and 'error' in error_elem.get_text().lower():
                error_text = error_elem.get_text()
                print(f"访问被拦截: {error_text[:200]}")
                browser.close()
                return {'success': False, 'error': '被反爬机制拦截', 'title': title}
            
            # 提取内容
            data = extract_zhihu(soup)
            
            # 截图保存（用于调试）
            screenshot_path = '/tmp/zhihu_stealth.png'
            page.screenshot(path=screenshot_path, full_page=True)
            print(f"截图已保存: {screenshot_path}")
            
            browser.close()
            
            return {
                'success': True,
                'url': url,
                'page_title': title,
                **data,
                'content_length': len(data.get('content', '')),
                'screenshot': screenshot_path
            }
            
        except Exception as e:
            print(f"错误: {e}")
            # 尝试截图看发生了什么
            try:
                page.screenshot(path='/tmp/zhihu_error.png', full_page=True)
                print(f"错误截图: /tmp/zhihu_error.png")
            except:
                pass
            browser.close()
            return {'success': False, 'error': str(e), 'url': url}


if __name__ == '__main__':
    if len(sys.argv) < 2:
        print(json.dumps({'success': False, 'error': '请提供文章链接'}, ensure_ascii=False))
        sys.exit(1)
    
    url = sys.argv[1]
    result = extract_with_stealth(url)
    print(json.dumps(result, ensure_ascii=False, indent=2))


path: notion_screenshot.py
content: #!/usr/bin/env python3
"""
截图调试 Notion 页面
"""

import sys
from playwright.sync_api import sync_playwright

def screenshot_notion(url):
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context(
            user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
            viewport={'width': 1280, 'height': 800}
        )
        page = context.new_page()
        
        try:
            print(f"正在访问: {url}")
            # Notion 可能需要更长的加载时间
            page.goto(url, wait_until='domcontentloaded', timeout=60000)
            page.wait_for_timeout(10000)  # 等待 10 秒让内容加载
            
            # 截图
            screenshot_path = '/tmp/notion_page.png'
            page.screenshot(path=screenshot_path, full_page=True)
            print(f"截图已保存: {screenshot_path}")
            
            # 获取标题
            title = page.title()
            print(f"页面标题: {title}")
            
            browser.close()
            return {'success': True, 'screenshot': screenshot_path, 'title': title}
            
        except Exception as e:
            print(f"错误: {e}")
            # 错误截图
            try:
                page.screenshot(path='/tmp/notion_error.png')
                print(f"错误截图: /tmp/notion_error.png")
            except:
                pass
            browser.close()
            return {'success': False, 'error': str(e)}

if __name__ == '__main__':
    url = sys.argv[1] if len(sys.argv) > 1 else 'https://www.notion.so/Playwright-CLI-Claude-Code-1bd25b1f28234a45b69b3f5d75a595c1'
    result = screenshot_notion(url)
    print(result)


path: notion_scroll.py
content: #!/usr/bin/env python3
"""
滚动截取 Notion 页面完整内容
"""

import sys
from playwright.sync_api import sync_playwright

def scroll_and_capture(url):
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context(
            user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
            viewport={'width': 1280, 'height': 2000}  # 更高的视口
        )
        page = context.new_page()
        
        try:
            print(f"正在访问: {url}")
            page.goto(url, wait_until='domcontentloaded', timeout=60000)
            page.wait_for_timeout(10000)  # 等待 10 秒
            
            # 滚动页面加载更多内容
            for i in range(5):
                page.evaluate('window.scrollBy(0, 800)')
                page.wait_for_timeout(1000)
            
            # 回到顶部
            page.evaluate('window.scrollTo(0, 0)')
            page.wait_for_timeout(1000)
            
            # 截图
            screenshot_path = '~/.openclaw/workspace/notion_article_full.png'
            page.screenshot(path=screenshot_path, full_page=True)
            print(f"完整截图已保存: {screenshot_path}")
            
            # 获取标题
            title = page.title()
            print(f"页面标题: {title}")
            
            # 获取文本内容
            text_content = page.evaluate('''() => {
                const elements = document.querySelectorAll('p, h1, h2, h3, li');
                return Array.from(elements).map(e => e.innerText).filter(t => t.length > 5);
            }''')
            
            browser.close()
            
            return {
                'success': True,
                'title': title,
                'content': text_content
            }
            
        except Exception as e:
            print(f"错误: {e}")
            browser.close()
            return {'success': False, 'error': str(e)}

if __name__ == '__main__':
    url = sys.argv[1] if len(sys.argv) > 1 else 'https://www.notion.so/Playwright-CLI-Claude-Code-1bd25b1f28234a45b69b3f5d75a595c1?source=copy_link'
    result = scroll_and_capture(url)
    
    if result['success']:
        print("\n=== 文章内容 ===\n")
        for i, text in enumerate(result['content'][:50], 1):  # 限制输出数量
            print(f"{i}. {text}")
    else:
        print(result)


path: package.json
content: {
  "name": "weixin-reader",
  "version": "1.1.0",
  "description": "提取微信公众号文章内容，支持任意公众号文章链接的内容抓取和结构化输出",
  "main": "extract.py",
  "scripts": {
    "test": "python extract.py"
  },
  "keywords": ["weixin", "微信公众号", "文章提取", "爬虫"],
  "author": "nalin3166-collab",
  "license": "MIT"
}


path: requirements.txt
content: playwright
playwright-stealth
beautifulsoup4
dnspython

path: search_article.py
content: #!/usr/bin/env python3
"""
搜索文章的其他来源
"""

import sys
import json
import re
from playwright.sync_api import sync_playwright
from bs4 import BeautifulSoup


def search_google(query):
    """使用 Google 搜索"""
    
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context(
            user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
        )
        page = context.new_page()
        
        try:
            # 搜索
            search_url = f"https://www.google.com/search?q={query.replace(' ', '+')}"
            print(f"搜索: {search_url}")
            
            page.goto(search_url, wait_until='networkidle', timeout=30000)
            page.wait_for_timeout(3000)
            
            html = page.content()
            soup = BeautifulSoup(html, 'html.parser')
            
            # 提取搜索结果
            results = []
            
            # Google 搜索结果选择器
            for elem in soup.select('div.g, .yuRUbf'):
                link_elem = elem.select_one('a')
                title_elem = elem.select_one('h3')
                snippet_elem = elem.select_one('.VwiC3b, .s3v94d')
                
                if link_elem and title_elem:
                    url = link_elem.get('href', '')
                    title = title_elem.get_text(strip=True)
                    snippet = snippet_elem.get_text(strip=True) if snippet_elem else ''
                    
                    # 过滤掉 Google 自己的链接
                    if url and not url.startswith('/'):
                        results.append({
                            'title': title,
                            'url': url,
                            'snippet': snippet[:200] if snippet else ''
                        })
            
            browser.close()
            
            return {
                'success': True,
                'query': query,
                'results_count': len(results),
                'results': results[:10]  # 返回前10个结果
            }
            
        except Exception as e:
            browser.close()
            return {'success': False, 'error': str(e)}


if __name__ == '__main__':
    query = ' '.join(sys.argv[1:]) if len(sys.argv) > 1 else 'Playwright CLI Claude Code 傅红雪'
    result = search_google(query)
    print(json.dumps(result, ensure_ascii=False, indent=2))


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector:  ()