]+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''
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')})',
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''
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
)