# -*- coding: utf-8 -*- """ 网站信息抓取工具 """ import requests from bs4 import BeautifulSoup from urllib.parse import urljoin, urlparse import os import re from PIL import Image from io import BytesIO class WebsiteFetcher: """网站信息抓取器""" def __init__(self, timeout=10): self.timeout = timeout self.headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' } def fetch_website_info(self, url): """ 抓取网站信息 Args: url: 网站URL Returns: dict: 包含name, description, logo_url的字典,失败返回None """ try: # 确保URL包含协议 if not url.startswith(('http://', 'https://')): url = 'https://' + url # 请求网页 response = requests.get(url, headers=self.headers, timeout=self.timeout, allow_redirects=True) response.raise_for_status() response.encoding = response.apparent_encoding # 自动检测编码 # 解析HTML soup = BeautifulSoup(response.text, 'html.parser') # 提取信息 info = { 'name': self._extract_title(soup), 'description': self._extract_description(soup), 'logo_url': self._extract_logo(soup, url) } return info except Exception as e: print(f"抓取网站信息失败: {str(e)}") return None def fetch_skill_info(self, url): """ 抓取 Skill 信息(GitHub 仓库) Args: url: GitHub 仓库URL Returns: dict: 包含name, short_desc, github_url, source_repo的字典 """ try: # 解析 GitHub URL # 支持格式: https://github.com/user/repo 或 https://github.com/user/repo/tree/main parsed = urlparse(url) if 'github.com' not in parsed.netloc: return None path_parts = [p for p in parsed.path.split('/') if p] if len(path_parts) < 2: return None owner = path_parts[0] repo = path_parts[1].replace('.git', '') # 获取仓库信息 api_url = f"https://api.github.com/repos/{owner}/{repo}" response = requests.get(api_url, headers=self.headers, timeout=self.timeout) if response.status_code != 200: return None repo_info = response.json() # 获取 .claude 目录下的 skills skills_url = f"https://api.github.com/repos/{owner}/{repo}/contents/.claude/skills" skills_response = requests.get(skills_url, headers=self.headers, timeout=self.timeout) skills = [] if skills_response.status_code == 200: skills_data = skills_response.json() for item in skills_data: if item.get('type') == 'file' and item['name'].endswith('.md'): # 获取 skill 文件内容 skill_content = requests.get(item['download_url'], headers=self.headers, timeout=self.timeout) if skill_content.status_code == 200: skill_info = self._parse_skill_file( item['name'], skill_content.text, item.get('html_url') or item.get('download_url') or '' ) if skill_info: skills.append(skill_info) summary = self._summarize_skills(repo_info, skills) return { 'name': summary.get('name') or repo_info.get('name', ''), 'short_desc': summary.get('short_desc') or repo_info.get('description', ''), 'description': summary.get('description', ''), 'github_url': url, 'source_repo': f"{owner}/{repo}", 'usage': summary.get('usage', ''), 'examples': summary.get('examples', ''), 'skills': skills } except Exception as e: print(f"抓取 Skill 信息失败: {str(e)}") return None def _parse_skill_file(self, filename, content, source_url=''): """解析 skill 文件内容""" try: lines = [line.rstrip() for line in content.strip().split('\n')] fallback_name = filename.replace('.md', '') title = fallback_name for line in lines: if line.startswith('# '): title = line[2:].strip() break sections = self._extract_markdown_sections(lines) desc = self._pick_section( sections, ['描述', '简介', 'description', 'overview', 'summary', 'what it does'] ) use_cases = self._pick_section( sections, ['应用场景', '适用场景', '使用场景', 'use cases', 'when to use', 'use case'] ) usage = self._pick_section( sections, ['使用方法', '用法', 'usage', 'how to use', 'quick start'] ) examples = self._pick_section( sections, ['示例', 'examples', 'example'] ) if not desc: desc = self._first_paragraph(lines) content_outline = self._extract_outline(sections) return { 'name': title, 'description': self._clean_text(desc)[:240], 'use_cases': self._clean_text(use_cases)[:240], 'usage': self._clean_text(usage)[:600], 'examples': self._clean_text(examples)[:600], 'content_outline': content_outline, 'source_url': source_url } except Exception: return None def _extract_markdown_sections(self, lines): sections = {} current = '__intro__' buffer = [] for raw_line in lines: line = raw_line.strip() if line.startswith('##'): sections[current] = '\n'.join(buffer).strip() current = re.sub(r'^#+\s*', '', line).strip().lower() buffer = [] else: buffer.append(raw_line) sections[current] = '\n'.join(buffer).strip() return sections def _pick_section(self, sections, keywords): for key, value in sections.items(): low = key.lower() for keyword in keywords: if keyword.lower() in low and value.strip(): return value.strip() return '' def _first_paragraph(self, lines): paragraph = [] for line in lines: stripped = line.strip() if not stripped or stripped.startswith('#'): if paragraph: break continue if stripped.startswith('- ') or stripped.startswith('* ') or stripped.startswith('```'): continue paragraph.append(stripped) if len(' '.join(paragraph)) > 220: break return ' '.join(paragraph) def _extract_outline(self, sections): outlines = [] for key in sections.keys(): if key != '__intro__': clean_key = key.strip() if clean_key: outlines.append(clean_key) return outlines[:8] def _clean_text(self, text): if not text: return '' text = re.sub(r'```.*?```', '', text, flags=re.S) text = re.sub(r'`([^`]+)`', r'\1', text) text = re.sub(r'\[(.*?)\]\((.*?)\)', r'\1', text) text = re.sub(r'^[-*]\s+', '', text, flags=re.M) text = re.sub(r'\n{2,}', '\n', text) text = re.sub(r'\s+', ' ', text) return text.strip() def _summarize_skills(self, repo_info, skills): if not skills: return { 'name': repo_info.get('name', ''), 'short_desc': repo_info.get('description', ''), 'description': repo_info.get('description', ''), 'usage': '', 'examples': '' } skill_names = [s['name'] for s in skills if s.get('name')] use_cases = [s['use_cases'] for s in skills if s.get('use_cases')] descs = [s['description'] for s in skills if s.get('description')] outlines = [] for s in skills: outlines.extend(s.get('content_outline', [])) repo_desc = (repo_info.get('description') or '').strip() short_desc = repo_desc or (descs[0] if descs else '') parts = [] if repo_desc: parts.append(f"仓库简介:{repo_desc}") if skill_names: shown = '、'.join(skill_names[:5]) more = f" 等 {len(skill_names)} 个 skill" if len(skill_names) > 5 else '' parts.append(f"包含内容:{shown}{more}") if use_cases: parts.append(f"应用场景:{use_cases[0]}") elif descs: parts.append(f"应用场景:{descs[0]}") if outlines: uniq = [] for item in outlines: if item not in uniq: uniq.append(item) parts.append(f"文档涵盖:{'、'.join(uniq[:6])}") usage_lines = [] for s in skills[:5]: line = f"- {s['name']}" if s.get('use_cases'): line += f":{s['use_cases']}" elif s.get('description'): line += f":{s['description']}" usage_lines.append(line) examples_lines = [] for s in skills[:3]: if s.get('source_url'): examples_lines.append(f"{s['name']}:{s['source_url']}") return { 'name': repo_info.get('name', ''), 'short_desc': short_desc[:180], 'description': '\n'.join(parts)[:1200], 'usage': '\n'.join(usage_lines)[:1200], 'examples': '\n'.join(examples_lines)[:1200] } def _extract_title(self, soup): """提取网站标题""" # 优先使用 og:title og_title = soup.find('meta', property='og:title') if og_title and og_title.get('content'): return og_title['content'].strip() # 使用 title 标签 title_tag = soup.find('title') if title_tag: return title_tag.get_text().strip() return '' def _extract_description(self, soup): """提取网站描述""" # 优先使用 og:description og_desc = soup.find('meta', property='og:description') if og_desc and og_desc.get('content'): return og_desc['content'].strip() # 使用 meta description meta_desc = soup.find('meta', attrs={'name': 'description'}) if meta_desc and meta_desc.get('content'): return meta_desc['content'].strip() return '' def _extract_logo(self, soup, base_url): """提取网站 Logo URL""" # 优先查找 favicon favicon = soup.find('link', rel='icon') or soup.find('link', rel='shortcut icon') if favicon and favicon.get('href'): return urljoin(base_url, favicon['href']) # 查找 apple-touch-icon apple_icon = soup.find('link', rel='apple-touch-icon') if apple_icon and apple_icon.get('href'): return urljoin(base_url, apple_icon['href']) # 使用 /favicon.ico parsed = urlparse(base_url) return f"{parsed.scheme}://{parsed.netloc}/favicon.ico" def download_logo(self, logo_url, save_dir='static/logos'): """ 下载 Logo 到本地 Args: logo_url: Logo URL save_dir: 保存目录 Returns: str: 保存后的文件路径,失败返回 None """ try: # 确保目录存在 os.makedirs(save_dir, exist_ok=True) # 下载图片 response = requests.get(logo_url, headers=self.headers, timeout=10, stream=True) if response.status_code != 200: return None # 检查是否是图片 content_type = response.headers.get('Content-Type', '').lower() if 'image' not in content_type: return None # 生成文件名 import time import hashlib ext = '.png' if 'jpeg' in content_type or 'jpg' in content_type: ext = '.jpg' elif 'gif' in content_type: ext = '.gif' elif 'svg' in content_type: ext = '.svg' elif 'ico' in content_type: ext = '.ico' timestamp = str(int(time.time() * 1000)) hash_name = hashlib.md5(f"{logo_url}{timestamp}".encode()).hexdigest()[:16] filename = f"logo_{hash_name}{ext}" filepath = os.path.join(save_dir, filename) # 保存图片 with open(filepath, 'wb') as f: for chunk in response.iter_content(8192): f.write(chunk) # 验证图片是否有效 try: with Image.open(filepath) as img: img.verify() return filepath except: # 图片无效,删除文件 if os.path.exists(filepath): os.remove(filepath) return None except Exception as e: print(f"下载 Logo 失败: {str(e)}") return None