""" 文章采集路由 - 从网页URL采集内容 支持SPA网站采集 """ import os import re import uuid import requests from datetime import datetime from typing import Optional, List from urllib.parse import urljoin, urlparse try: from bs4 import BeautifulSoup BS4_AVAILABLE = True except ImportError: BS4_AVAILABLE = False # Playwright用于SPA采集 try: from playwright.sync_api import sync_playwright PLAYWRIGHT_AVAILABLE = True except ImportError: PLAYWRIGHT_AVAILABLE = False # Playwright Stealth 用于绕过WAF try: from playwright_stealth import stealth_sync STEALTH_AVAILABLE = True except ImportError: STEALTH_AVAILABLE = False from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import JSONResponse from pydantic import BaseModel from sqlalchemy.orm import Session from app.database import get_db from app.routers.auth import get_current_admin from PIL import Image as PILImage router = APIRouter(prefix="/crawl", tags=["文章采集"]) # 上传配置 from app.config import get_settings settings = get_settings() def get_upload_dir(): """获取上传目录路径 - 扁平存储(统一指向项目根目录 storage)""" base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) upload_dir = os.path.join(base_dir, settings.UPLOAD_DIR) os.makedirs(upload_dir, exist_ok=True) # 缩略图目录 thumbnail_dir = os.path.join(upload_dir, "thumbnails") os.makedirs(thumbnail_dir, exist_ok=True) return upload_dir def get_crawl_temp_dir(): """获取采集临时目录 - 跨平台(统一指向项目根目录 storage/tmp/crawl)""" if os.name == 'nt': # Windows base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) temp_dir = os.path.join(base_dir, '..', 'storage', 'tmp', 'crawl') else: # Linux/macOS temp_dir = '/tmp/news_crawl' os.makedirs(temp_dir, exist_ok=True) # 缩略图临时目录 thumb_dir = os.path.join(temp_dir, 'thumbnails') os.makedirs(thumb_dir, exist_ok=True) return temp_dir def generate_unique_filename(original_filename: str) -> str: """生成唯一的文件名""" ext = os.path.splitext(original_filename)[1].lower() if not ext: ext = '.jpg' timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') unique_id = uuid.uuid4().hex[:8] return f"{timestamp}_{unique_id}{ext}" class CrawlRequest(BaseModel): url: str use_playwright: bool = False class CrawlImage(BaseModel): original_url: str local_path: Optional[str] = None filename: Optional[str] = None success: bool = False class CrawlResponse(BaseModel): success: bool title: Optional[str] = None content: Optional[str] = None summary: Optional[str] = None images: List[dict] = [] error: Optional[str] = None def fix_encoding(text: str) -> str: """修复UTF-8编码问题""" if not text: return text garbled_patterns = ['é', 'è', 'Ã', 'Â', 'Ã¥', 'ü', 'ö', 'ä', 'ß', 'â€', 'æ', 'å', 'è', '§'] has_garbled = any(pattern in text for pattern in garbled_patterns) if has_garbled: try: fixed = text.encode('latin1').decode('utf-8') still_garbled = any(p in fixed for p in garbled_patterns) if still_garbled: try: fixed = fixed.encode('latin1').decode('utf-8') except: pass return fixed except (UnicodeEncodeError, UnicodeDecodeError): pass return text def clean_html_tags(text: str) -> str: """移除文本中的HTML标签""" text = re.sub(r'<[^>]+>', '', text) text = text.replace(' ', ' ') text = text.replace('<', '<') text = text.replace('>', '>') text = text.replace('&', '&') text = text.replace('"', '"') text = text.replace(''', "'") return text.strip() def fix_math_formulas(text: str) -> str: """修复数学公式格式,处理混合格式问题(从 personal-blog 移植)""" # 1. 清理已渲染的数学符号(保留LaTeX代码) text = re.sub(r'[⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿⁱ]+', '', text) # 2. 规范化反斜杠(双重转义变单重) text = text.replace('\\\\', '\\') # 3. 检测块级公式(多行或包含 array, matrix 等) block_keywords = ['\\begin{array}', '\\begin{matrix}', '\\begin{equation}', '\\sum_', '\\int_', '\\prod_', '\\lim_'] lines = text.split('\n') result_lines = [] in_block = False block_content = [] for line in lines: stripped = line.strip() # 检测块级公式开始 if any(kw in stripped for kw in block_keywords): if not in_block: in_block = True block_content = [] block_content.append(line) continue # 如果在块内,继续收集 if in_block: block_content.append(line) # 检测块结束 if '\\end{array}' in stripped or '\\end{matrix}' in stripped or '\\end{equation}' in stripped: block_text = '\n'.join(block_content) if not block_text.startswith('$$') and not block_text.startswith('\\['): result_lines.append('$$' + block_text + '$$') else: result_lines.append(block_text) in_block = False block_content = [] continue # 处理行内公式 # 如果一行中有明显的LaTeX命令,但还没有被包裹 if re.search(r'\\[a-zA-Z]+', line) and not re.search(r'\\\(|\\\)|\\\[|\\\]', line): # 简单处理:如果包含复杂结构,作为块级;否则作为行内 if re.search(r'\\(frac|sum|int|prod|lim)\{', line): line = '$$' + line + '$$' else: line = '\\(' + line + '\\)' result_lines.append(line) # 处理未闭合的块 if in_block and block_content: block_text = '\n'.join(block_content) result_lines.append('$$' + block_text + '$$') return '\n'.join(result_lines) def extract_title_from_html(html: str) -> str: """从HTML中提取标题""" # Open Graph title og_title_match = re.search( r']+property=["\']og:title["\'][^>]+content=["\']([^"\']+)["\']', html, re.IGNORECASE ) if not og_title_match: og_title_match = re.search( r']+content=["\']([^"\']+)["\'][^>]+property=["\']og:title["\']', html, re.IGNORECASE ) if og_title_match: title = clean_html_tags(og_title_match.group(1)) if len(title) > 5: title = re.sub(r'\s+[–—-]\s+.*$', '', title).strip() return title # 博客园 cnblog_title = re.search(r']+class=["\'][^"\']*postTitle[^"\']*["\'][^>]*>(.*?)', html, re.IGNORECASE | re.DOTALL) if cnblog_title: title = clean_html_tags(cnblog_title.group(1)) if len(title) > 5: return title # 人人都是产品经理 (woshipm.com) woshipm_title = re.search(r'<[^>]+class=["\'][^"\']*article--title[^"\']*["\'][^>]*>(.*?)]+>', html, re.IGNORECASE | re.DOTALL) if woshipm_title: title = clean_html_tags(woshipm_title.group(1)) if len(title) > 5: return title # 菜鸟教程 runoob_title = re.search(r']*class=["\']article-intro["\'][^>]*>.*?]*>(.*?)', html, re.IGNORECASE | re.DOTALL) if runoob_title: title = clean_html_tags(runoob_title.group(1)) if len(title) > 5 and "菜鸟教程" not in title: return title # title标签 title_match = re.search(r']*>(.*?)', html, re.IGNORECASE | re.DOTALL) if title_match: title = clean_html_tags(title_match.group(1)) title = re.sub(r'\s+[|│┃]\s+.*$', '', title).strip() title = re.sub(r'\s+[-–—]\s+[^\s]*教程.*$', '', title).strip() title = re.sub(r'\s+[-–—]\s+[^\s]*博客.*$', '', title).strip() if len(title) > 5: return title # h1标题 h1_matches = re.findall(r']*>(.*?)', html, re.IGNORECASE | re.DOTALL) for h1 in h1_matches: title = clean_html_tags(h1) if len(title) > 5: return title # Twitter Card title twitter_title_match = re.search( r']+name=["\']twitter:title["\'][^>]+content=["\']([^"\']+)["\']', html, re.IGNORECASE ) if twitter_title_match: title = clean_html_tags(twitter_title_match.group(1)) if len(title) > 5: return title return "未命名文章" def extract_main_content(html: str) -> str: """提取主要内容""" if BS4_AVAILABLE: try: soup = BeautifulSoup(html, 'html.parser') for tag in soup(['script', 'style']): tag.decompose() selectors = [ '.article--content.grap', '#cnblogs_post_body', '.article-intro', 'article', 'main', '.post-content', '.article-content', '.entry-content', '#content', '#main-content', ] for selector in selectors: elem = soup.select_one(selector) if elem: return str(elem) body = soup.find('body') if body: return str(body) except Exception as e: print(f"BeautifulSoup失败: {e}") # 正则回退 html = re.sub(r']*>.*?', '', html, flags=re.DOTALL | re.IGNORECASE) html = re.sub(r']*>.*?', '', html, flags=re.DOTALL | re.IGNORECASE) patterns = [ r']*class=["\']article--content grap["\'][^>]*>(.*?)\s*\s*]*class=["\']article--footer', r']*class=["\']article--content grap["\'][^>]*>(.*?)\s*\s*]*class=["\']article-bottom-meta', r']*class=["\']article-intro["\'][^>]*>(.*?)]*class=["\'](?:google-auto-placed|pre|next)', r']+id=["\']cnblogs_post_body["\'][^>]*>(.*?)\s*]+id=["\']blog_post_info_wrap["\']', r']+id=["\']cnblogs_post_body["\'][^>]*>(.*?)', r']*>(.*?)', r']*>(.*?)', r']*class=["\'][^"\']*(?:content|article|post|entry)[^"\']*["\'][^>]*>(.*?)', ] for pattern in patterns: match = re.search(pattern, html, re.DOTALL | re.IGNORECASE) if match: return match.group(1) return html def html_to_markdown(html: str, base_url: str = "") -> str: """HTML转Markdown""" html = fix_encoding(html) parsed_base = urlparse(base_url) if base_url else None base_is_https = parsed_base and parsed_base.scheme == 'https' def make_absolute_url(src: str) -> str: if not src: return src if src.startswith('//'): src = 'https:' + src elif src.startswith('/') and base_url: src = f"{parsed_base.scheme}://{parsed_base.netloc}{src}" elif not src.startswith(('http://', 'https://')) and base_url: src = urljoin(base_url, src) if base_is_https and src.startswith('http://'): src = 'https://' + src[7:] return src if BS4_AVAILABLE: soup = BeautifulSoup(html, 'html.parser') for img in soup.find_all('img'): src = img.get('src', '') alt = img.get('alt', '') if src: src = make_absolute_url(src) md_img = f'![{alt}]({src})' img.replace_with(md_img) for tag in soup.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']): level = int(tag.name[1]) tag.replace_with(f'\n{"#" * level} {tag.get_text().strip()}\n') for p in soup.find_all('p'): p.replace_with(f'\n{p.get_text()}\n') for br in soup.find_all('br'): br.replace_with('\n') for pre in soup.find_all('pre'): code = pre.find('code') if code: code_text = code.get_text() lang = '' code_classes = code.get('class', []) for cls in code_classes: if 'language-' in cls or 'lang-' in cls: lang = cls.replace('language-', '').replace('lang-', '') break md_code = f'\n```{lang}\n{code_text}\n```\n' pre.replace_with(md_code) else: pre_text = pre.get_text() md_code = f'\n```\n{pre_text}\n```\n' pre.replace_with(md_code) for code in soup.find_all('code'): if not code.find_parent('pre'): code.replace_with(f'`{code.get_text()}`') for tag in soup.find_all(['strong', 'b']): tag.replace_with(f'**{tag.get_text()}**') for tag in soup.find_all(['em', 'i']): tag.replace_with(f'*{tag.get_text()}*') text = soup.get_text() else: text = html text = re.sub( r']+src=["\']([^"\']+)["\'][^>]*>', lambda m: f'![图片]({make_absolute_url(m.group(1))})', text, flags=re.IGNORECASE ) text = re.sub(r']*>(.*?)', r'\n# \1\n', text, flags=re.DOTALL | re.IGNORECASE) text = re.sub(r']*>(.*?)', r'\n## \1\n', text, flags=re.DOTALL | re.IGNORECASE) text = re.sub(r']*>(.*?)', r'\n### \1\n', text, flags=re.DOTALL | re.IGNORECASE) text = re.sub(r']*>(.*?)

', r'\n\1\n', text, flags=re.DOTALL | re.IGNORECASE) text = re.sub(r'', '\n', text, flags=re.IGNORECASE) text = re.sub(r'<[^>]+>', '', text) text = text.replace(' ', ' ').replace('<', '<').replace('>', '>') text = text.replace('&', '&').replace('"', '"') lines = text.split('\n') cleaned = [] prev_empty = False for line in lines: stripped = line.strip() if stripped == '': if not prev_empty: cleaned.append('') prev_empty = True else: cleaned.append(line) prev_empty = False text = '\n'.join(cleaned).strip() # 修复数学公式格式 text = fix_math_formulas(text) return text def extract_images_from_markdown(markdown_content: str, base_url: str) -> List[CrawlImage]: """从Markdown提取图片""" images = [] parsed_base = urlparse(base_url) base_is_https = parsed_base.scheme == 'https' img_pattern = r'!\[([^\]]*)\]\(([^)]+)\)' for match in re.finditer(img_pattern, markdown_content): img_url = match.group(2) if img_url.startswith('/uploads/') or img_url.startswith('/storage/'): continue if img_url.startswith('//'): img_url = 'https:' + img_url elif img_url.startswith('/'): img_url = f"{parsed_base.scheme}://{parsed_base.netloc}{img_url}" elif not img_url.startswith(('http://', 'https://')): img_url = urljoin(base_url, img_url) if base_is_https and img_url.startswith('http://'): img_url = 'https://' + img_url[7:] images.append(CrawlImage(original_url=img_url)) return images def is_spa_site(html: str) -> bool: """检测是否为SPA""" indicators = [ '
', '
', 'You need to enable JavaScript', 'Loading...', 'data-reactroot', 'window.__INITIAL_STATE__' ] html_lower = html.lower() for indicator in indicators: if indicator.lower() in html_lower: return True text_content = re.sub(r'<[^>]+>', '', html) text_content = re.sub(r'\s+', '', text_content) if len(text_content) < 200: return True return False def crawl_with_playwright(url: str) -> dict: """使用Playwright采集SPA(同步API,避免Windows asyncio子进程问题)""" if not PLAYWRIGHT_AVAILABLE: raise Exception("Playwright未安装") is_wechat = 'mp.weixin.qq.com' in url is_juejin = 'juejin.cn' in url is_aliyun = 'developer.aliyun.com' in url is_segmentfault = 'segmentfault.com' in url is_tencent_cloud = 'cloud.tencent.com' in url with sync_playwright() as p: browser = p.chromium.launch( headless=True, args=[ '--disable-blink-features=AutomationControlled', '--disable-web-security', '--disable-features=IsolateOrigins,site-per-process', '--disable-dev-shm-usage', '--no-sandbox', '--disable-setuid-sandbox', '--disable-accelerated-2d-canvas', '--disable-gpu', '--window-size=1920,1080', ] ) context = browser.new_context( viewport={'width': 1920, 'height': 1080}, 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', locale='zh-CN', timezone_id='Asia/Shanghai', permissions=['geolocation'], java_script_enabled=True, ) context.add_init_script(""" Object.defineProperty(navigator, 'webdriver', {get: () => undefined}); Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3, 4, 5]}); window.chrome = { runtime: {} }; """) page = context.new_page() if STEALTH_AVAILABLE: stealth_sync(page) page.goto(url, wait_until='networkidle', timeout=30000) if is_wechat: page.wait_for_timeout(5000) title = "" try: if page.locator('#activity_name').count() > 0: title = page.locator('#activity_name').first.inner_text() title = title.strip() except: pass if not title: title = page.title() content_html = "" try: if page.locator('#js_content').count() > 0: content_html = page.locator('#js_content').first.inner_html() except: pass images = [] try: img_elements = page.locator('#js_content img').all() for img in img_elements[:10]: try: src = img.get_attribute('data-src') or img.get_attribute('src') if src and src.startswith('http'): images.append(CrawlImage(original_url=src)) except: pass except: pass context.close() browser.close() return {'title': title or "未命名文章", 'content_html': content_html, 'images': images} # 普通SPA处理 if is_juejin: try: page.wait_for_selector('article', timeout=15000) except: pass page.wait_for_timeout(3000) elif is_aliyun: # 阿里云开发者社区:等待语雀编辑器内容加载 try: page.wait_for_selector('.article-content', timeout=15000) except: pass # 语雀编辑器内容懒加载,需要滚动触发 page.wait_for_timeout(2000) for _ in range(5): page.evaluate('window.scrollBy(0, 800)') page.wait_for_timeout(500) page.wait_for_timeout(2000) elif is_segmentfault: # SegmentFault 需要更长的等待时间 print("[Playwright] 等待 SegmentFault 页面加载...") try: page.wait_for_load_state('networkidle', timeout=20000) except: pass page.wait_for_timeout(8000) print("[Playwright] 等待完成,开始提取") elif is_tencent_cloud: # 腾讯云开发者社区 try: page.wait_for_selector('[class*="markdown"]', timeout=15000) except: pass page.wait_for_timeout(3000) title = "" if is_juejin: for selector in ['article h1', 'h1']: try: if page.locator(selector).count() > 0: title = page.locator(selector).first.inner_text() if title.strip(): break except: continue elif is_segmentfault: # SegmentFault 标题选择器 sf_title_selectors = ['h1.article-title', 'h1', '.article-title', '[class*="title"]'] for selector in sf_title_selectors: try: count = page.locator(selector).count() if count > 0: t = page.locator(selector).first.inner_text() print(f"[Playwright] 标题选择器 '{selector}': '{t[:30] if t else '空'}...'") if t and t.strip(): title = t.strip() break except Exception as e: print(f"[Playwright] 标题选择器 '{selector}' 失败: {e}") elif is_tencent_cloud: for selector in ['h1', '.article-title', '[class*="title"]']: try: if page.locator(selector).count() > 0: title = page.locator(selector).first.inner_text() if title.strip(): break except: continue if not title: for selector in ['article h1', 'h1', '.article-title']: try: if page.locator(selector).count() > 0: title = page.locator(selector).first.inner_text() if title.strip(): break except: continue if not title: title = page.title() content_html = "" if is_juejin: try: if page.locator('.markdown-body').count() > 0: content_html = page.locator('.markdown-body').first.inner_html() elif page.locator('article').count() > 0: content_html = page.locator('article').first.inner_html() except: pass if content_html: content_html = re.sub(r']*>.*?', '', content_html, flags=re.DOTALL | re.IGNORECASE) content_html = re.sub(r']*>.*?', '', content_html, flags=re.DOTALL | re.IGNORECASE) content_html = re.sub(r'\s+style="[^"]*"', '', content_html, flags=re.IGNORECASE) content_html = re.sub(r'\s+class="[^"]*"', '', content_html, flags=re.IGNORECASE) elif is_aliyun: # 阿里云开发者社区:语雀编辑器内容 try: if page.locator('.article-content').count() > 0: content_html = page.locator('.article-content').first.inner_html() elif page.locator('.article-detail').count() > 0: content_html = page.locator('.article-detail').first.inner_html() except: pass if content_html: content_html = re.sub(r']*>.*?', '', content_html, flags=re.DOTALL | re.IGNORECASE) content_html = re.sub(r']*>.*?', '', content_html, flags=re.DOTALL | re.IGNORECASE) elif is_segmentfault: # SegmentFault 内容提取 sf_selectors = ['.article-content', '.markdown-body', 'article', '.post-content', '[data-id]', 'main', '#content', '.content'] for selector in sf_selectors: try: count = page.locator(selector).count() print(f"[Playwright] 检查选择器 '{selector}': {count} 个匹配") if count > 0: html = page.locator(selector).first.inner_html() if len(html) > 200: content_html = html print(f"[Playwright] 使用 '{selector}' 提取,长度: {len(content_html)}") break except Exception as e: print(f"[Playwright] 选择器 '{selector}' 失败: {e}") if not content_html: try: content_html = page.locator('body').first.inner_html() print(f"[Playwright] 使用 body,长度: {len(content_html)}") except Exception as e: print(f"[Playwright] 获取 body 失败: {e}") if content_html: content_html = re.sub(r']*>.*?', '', content_html, flags=re.DOTALL | re.IGNORECASE) content_html = re.sub(r']*>.*?', '', content_html, flags=re.DOTALL | re.IGNORECASE) content_html = re.sub(r']*class="[^"]*ad[^"]*"[^>]*>.*?', '', content_html, flags=re.DOTALL | re.IGNORECASE) elif is_tencent_cloud: # 腾讯云开发者社区 try: if page.locator('.mod-content__markdown').count() > 0: content_html = page.locator('.mod-content__markdown').first.inner_html() except: pass if not content_html: selectors = ['article', '.article-content', '.content', '.post-content', 'main'] for selector in selectors: try: elements = page.locator(selector).all() if elements: parts = [] for el in elements: try: html = el.inner_html().strip() if html and len(html) > 20: parts.append(html) except: pass if parts: combined = '\n'.join(parts) if len(combined) > 500: content_html = combined break except: continue # 最终回退:所有选择器都失败时,用页面可见文本 if not content_html: try: body_text = page.locator('body').first.inner_text() if body_text and len(body_text.strip()) > 50: lines = [l for l in body_text.split('\n') if l.strip()] content_html = '
' + '\n'.join(lines) + '
' except: pass images = [] parsed_url = urlparse(url) url_is_https = parsed_url.scheme == 'https' try: img_elements = page.locator('img').all() for img in img_elements[:10]: try: src = img.get_attribute('src') if src and not src.startswith('data:') and len(src) > 5: if src.startswith('//'): src = 'https:' + src elif src.startswith('/'): src = f"{parsed_url.scheme}://{parsed_url.netloc}{src}" elif not src.startswith(('http://', 'https://')): src = urljoin(url, src) if url_is_https and src.startswith('http://'): src = 'https://' + src[7:] images.append(CrawlImage(original_url=src)) except: pass except: pass context.close() browser.close() title = fix_encoding(title) if title else title content_html = fix_encoding(content_html) if content_html else content_html return {'title': title.strip() if title else "未命名文章", 'content_html': content_html, 'images': images} def convert_spa_html_to_markdown(html: str, images: List[CrawlImage], base_url: str) -> str: """SPA HTML转Markdown""" html = fix_encoding(html) text = re.sub(r']*>.*?', '', html, flags=re.DOTALL | re.IGNORECASE) text = re.sub(r']*>.*?', '', text, flags=re.DOTALL | re.IGNORECASE) parsed_base = urlparse(base_url) base_is_https = parsed_base.scheme == 'https' def img_to_markdown(match): src = match.group(1) if src.startswith('//'): src = 'https:' + src elif src.startswith('/'): src = f"{parsed_base.scheme}://{parsed_base.netloc}{src}" if base_is_https and src.startswith('http://'): src = 'https://' + src[7:] return f'![图片]({src})' text = re.sub(r']+src=["\']([^"\']+)["\'][^>]*>', img_to_markdown, text, flags=re.IGNORECASE) text = re.sub(r']*>\s*]*>(.*?)\s*', r'\n```\n\1\n```\n', text, flags=re.DOTALL | re.IGNORECASE) text = re.sub(r']*>(.*?)', r'`\1`', text, flags=re.DOTALL | re.IGNORECASE) text = re.sub(r']*>(.*?)', r'\n# \1\n', text, flags=re.DOTALL | re.IGNORECASE) text = re.sub(r']*>(.*?)', r'\n## \1\n', text, flags=re.DOTALL | re.IGNORECASE) text = re.sub(r']*>(.*?)', r'\n### \1\n', text, flags=re.DOTALL | re.IGNORECASE) text = re.sub(r']*>(.*?)

', r'\n\1\n', text, flags=re.DOTALL | re.IGNORECASE) text = re.sub(r'', '\n', text, flags=re.IGNORECASE) text = re.sub(r']*>(.*?)', r'**\1**', text, flags=re.DOTALL | re.IGNORECASE) text = re.sub(r'<[^>]+>', '', text) text = text.replace(' ', ' ').replace('<', '<').replace('>', '>') text = text.replace('&', '&').replace('"', '"') lines = text.split('\n') cleaned = [] prev_empty = False for line in lines: stripped = line.strip() if stripped == '': if not prev_empty: cleaned.append('') prev_empty = True else: cleaned.append(line) prev_empty = False text = '\n'.join(cleaned).strip() # 修复数学公式格式(掘金等平台) text = fix_math_formulas(text) return text @router.post("") async def crawl_article(request: CrawlRequest, db: Session = Depends(get_db), current_admin = Depends(get_current_admin)): """采集文章""" response_headers = { "Cache-Control": "no-cache, no-store, must-revalidate", "Content-Type": "application/json; charset=utf-8" } try: if not request.url.startswith(('http://', 'https://')): raise HTTPException(status_code=400, detail="无效的URL") is_wechat = 'mp.weixin.qq.com' in request.url is_juejin = 'juejin.cn' in request.url is_aliyun = 'developer.aliyun.com' in request.url if is_wechat or is_juejin or is_aliyun: if not PLAYWRIGHT_AVAILABLE: site_name = '微信' if is_wechat else ('掘金' if is_juejin else '阿里云') return JSONResponse( content={"success": False, "error": f"{site_name}文章需要Playwright"}, headers=response_headers ) import asyncio spa_result = await asyncio.to_thread(crawl_with_playwright, request.url) title = spa_result['title'] images = spa_result['images'] content_html = spa_result['content_html'] markdown_content = convert_spa_html_to_markdown(content_html, images, request.url) else: headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', 'Accept-Encoding': 'gzip, deflate, br', 'Cache-Control': 'max-age=0', 'Connection': 'keep-alive', 'Upgrade-Insecure-Requests': '1', 'Sec-Fetch-Dest': 'document', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Site': 'none', 'Sec-Fetch-User': '?1', } try: response = requests.get(request.url, headers=headers, timeout=30) response.raise_for_status() except requests.exceptions.HTTPError as e: if response.status_code in (468, 403, 429, 503) and PLAYWRIGHT_AVAILABLE: import asyncio spa_result = await asyncio.to_thread(crawl_with_playwright, request.url) title = spa_result['title'] images = spa_result['images'] content_html = spa_result['content_html'] markdown_content = convert_spa_html_to_markdown(content_html, images, request.url) return JSONResponse( content={ "success": True, "title": title, "url": request.url, "content": markdown_content, "images": [{"original_url": img.original_url} for img in images] }, headers=response_headers ) else: raise declared_encoding = None if response.encoding: declared_encoding = response.encoding if not declared_encoding or declared_encoding.lower() in ['iso-8859-1', 'latin-1']: raw_content = response.content charset_match = re.search(rb']+charset=["\']?([^"\'>\s]+)', raw_content, re.IGNORECASE) if charset_match: declared_encoding = charset_match.group(1).decode('ascii', errors='ignore') if declared_encoding and declared_encoding.lower() not in ['iso-8859-1', 'latin-1']: response.encoding = declared_encoding else: response.encoding = response.apparent_encoding or 'utf-8' html = response.text if is_spa_site(html): if not PLAYWRIGHT_AVAILABLE: return JSONResponse( content={"success": False, "error": "SPA网站需要Playwright"}, headers=response_headers ) import asyncio spa_result = await asyncio.to_thread(crawl_with_playwright, request.url) title = spa_result['title'] images = spa_result['images'] content_html = spa_result['content_html'] markdown_content = convert_spa_html_to_markdown(content_html, images, request.url) else: title = extract_title_from_html(html) main_content = extract_main_content(html) markdown_content = html_to_markdown(main_content, request.url) images = extract_images_from_markdown(markdown_content, request.url) # 下载图片到临时目录 temp_dir = get_crawl_temp_dir() upload_dir = get_upload_dir() downloaded_images = [] for i, img in enumerate(images[:5]): try: img_headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8', 'Referer': request.url, } # 微信公众号图片需要特殊 Referer if 'mmbiz.qpic.cn' in img.original_url: img_headers['Referer'] = 'https://mp.weixin.qq.com/' img_response = requests.get(img.original_url, headers=img_headers, timeout=30, stream=True) img_response.raise_for_status() content_type = img_response.headers.get('content-type', '') ext = '.jpg' if 'png' in content_type: ext = '.png' elif 'gif' in content_type: ext = '.gif' elif 'webp' in content_type: ext = '.webp' elif 'svg' in content_type: ext = '.svg' filename = f"crawl_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}{ext}" filepath = os.path.join(temp_dir, filename) with open(filepath, 'wb') as f: for chunk in img_response.iter_content(chunk_size=8192): f.write(chunk) # 检查文件大小,过滤防盗链占位图(小于1KB视为失败) file_size = os.path.getsize(filepath) if file_size < 1024: os.remove(filepath) print(f"图片太小({file_size} 字节),可能是防盗链占位图: {img.original_url}") continue # 验证文件是否为有效图片(SVG 除外,浏览器直接支持) is_svg = ext == '.svg' if not is_svg: try: with PILImage.open(filepath) as pil_img: pil_img.verify() # 验证图片完整性 except Exception: os.remove(filepath) print(f"文件不是有效图片,已删除: {img.original_url}") continue # 生成缩略图 - 放在临时目录的 thumbnails 子目录下 thumb_filename = f"{os.path.splitext(filename)[0]}.jpg" # 缩略图统一为 .jpg thumb_filepath = os.path.join(temp_dir, "thumbnails", thumb_filename) thumb_path = None if not is_svg: try: with PILImage.open(filepath) as pil_img: # 转换为 RGB(处理透明图片) if pil_img.mode in ('RGBA', 'P'): pil_img = pil_img.convert('RGB') pil_img.thumbnail((300, 300)) pil_img.save(thumb_filepath, "JPEG", quality=85) thumb_path = f"/storage/tmp/crawl/thumbnails/{thumb_filename}" except Exception as thumb_err: print(f"缩略图生成失败 {filename}: {thumb_err}") local_path = f"/storage/tmp/crawl/{filename}" img.local_path = local_path img.filename = filename img.success = True downloaded_images.append({ "original_url": img.original_url, "local_path": local_path, "thumb_path": thumb_path, "filename": filename }) # 替换内容中的URL为临时路径 if img.original_url in markdown_content: markdown_content = markdown_content.replace(img.original_url, local_path) except Exception as e: print(f"下载图片失败 {img.original_url}: {e}") summary = markdown_content[:200].replace('#', '').replace('*', '').strip() return JSONResponse( content={ "success": True, "title": title, "content": markdown_content, "summary": summary, "images": downloaded_images }, headers=response_headers ) except Exception as e: import traceback print(f"采集失败: {e}") print(traceback.format_exc()) return JSONResponse( content={"success": False, "error": f"采集失败: {str(e)}"}, headers=response_headers )