#!/usr/bin/env python3 """ 批量采集 niaodaifu.cn 健康资讯文章 """ import os import re import hashlib import requests import sqlite3 from datetime import datetime from urllib.parse import urljoin, urlparse from playwright.sync_api import sync_playwright from bs4 import BeautifulSoup, NavigableString from PIL import Image import io # ============ 配置 ============ BASE_DIR = os.path.dirname(os.path.abspath(__file__)) UPLOAD_DIR = os.path.join(BASE_DIR, "..", "storage", "admin", "images") DB_PATH = os.path.join(BASE_DIR, "cms.db") MENU_TO_CATEGORY = { 15: (2, "科普文章"), 7: (3, "肾友"), 8: (4, "糖友"), 6: (5, "健康"), } 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/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", } # ============ 工具函数 ============ def slugify(title): """生成 URL 友好的 slug""" import unicodedata slug = unicodedata.normalize('NFKC', title) slug = re.sub(r'[^\w\s-]', '', slug).strip().lower() slug = re.sub(r'[-\s]+', '-', slug) if not slug: slug = 'article' return slug def ensure_unique_slug(conn, slug): """确保 slug 唯一""" cursor = conn.cursor() original = slug suffix = 1 while True: cursor.execute('SELECT 1 FROM news_posts WHERE slug = ?', (slug,)) if not cursor.fetchone(): break slug = f"{original}-{suffix}" suffix += 1 return slug def download_image(url, module='news'): """下载图片到本地存储目录,返回文件路径和数据库记录信息""" if not url or url.startswith('data:'): return None, None # 过滤 135editor 占位图 if '135editor.com' in url and url.endswith('.gif'): return None, None try: resp = requests.get(url, headers=HEADERS, timeout=30) if resp.status_code != 200: return None, None content = resp.content if len(content) < 100: return None, None # 判断扩展名 ext = os.path.splitext(urlparse(url).path)[1].lower() if ext not in ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp'): # 尝试通过内容判断 if content[:2] == b'\xff\xd8': ext = '.jpg' elif content[:8] == b'\x89PNG\r\n\x1a\n': ext = '.png' elif content[:4] == b'GIF8': ext = '.gif' elif content[:4] == b' ' + line for line in lines) parts.append(quoted + '\n\n') elif tag in ('table', 'thead', 'tbody', 'tr', 'td', 'th'): # 简单处理表格:提取文本 text = html_to_markdown(child, base_url, downloaded_images).strip() if text: parts.append(text + ' ') elif tag == 'pre': code = child.get_text() parts.append(f'\n```\n{code}\n```\n\n') elif tag == 'code': code = child.get_text() parts.append(f'`{code}`') else: # 其他标签递归处理 text = html_to_markdown(child, base_url, downloaded_images) if text: parts.append(text) return ''.join(parts) def clean_markdown(md): """清理 Markdown 文本""" # 合并多个空行 md = re.sub(r'\n{3,}', '\n\n', md) # 去除行首行尾空白 md = md.strip() return md def parse_articles_from_page(page): """从当前页面解析文章列表""" articles = [] html = page.content() soup = BeautifulSoup(html, 'html.parser') medias = soup.find_all('div', class_='media') for media in medias: heading_a = media.find('a', class_='media-heading') if not heading_a: continue title = heading_a.get_text(strip=True) href = heading_a.get('href', '') if not href: continue m = re.search(r'article_id=(\d+)', href) if not m: continue article_id = int(m.group(1)) cover_url = None left_a = media.find('a', class_='media-left') if left_a: img = left_a.find('img') if img: cover_url = img.get('src') time_str = '' time_el = media.find('span', class_='time') if time_el: time_str = time_el.get_text(strip=True) articles.append({ 'article_id': article_id, 'title': title, 'href': urljoin('https://niaodaifu.cn', href), 'cover_url': cover_url, 'time': time_str, }) return articles, soup # ============ 采集函数 ============ def get_article_list(page, menu_id): """获取指定栏目的所有文章列表(通过点击分页按钮翻页)""" base_url = f"https://niaodaifu.cn/health-information?menu_id={menu_id}" all_articles = [] seen_ids = set() page.goto(base_url, wait_until='networkidle', timeout=30000) page.wait_for_timeout(3000) # 获取总条数 html = page.content() soup = BeautifulSoup(html, 'html.parser') total = 0 total_el = soup.find('span', class_='el-pagination__total') if total_el: m = re.search(r'(\d+)', total_el.get_text()) if m: total = int(m.group(1)) # 获取页码按钮总数(最后一个数字页码) pager_btns = page.query_selector_all('.el-pager li.number') total_pages = 1 if pager_btns: # 最后一个页码按钮的文本就是总页数(可能被省略,需要点击更多后才知道) # 先查 el-pagination__total if total > 0: page_size_text = '' size_select = soup.find('input', {'class': 'el-input__inner'}) page_size = 10 # 默认每页10条 total_pages = max(1, (total + page_size - 1) // page_size) print(f" 栏目 {menu_id} 共 {total} 篇文章,预计 {total_pages} 页") current_page = 1 while True: articles, soup = parse_articles_from_page(page) new_count = 0 for art in articles: if art['article_id'] not in seen_ids: seen_ids.add(art['article_id']) all_articles.append(art) new_count += 1 print(f" 第 {current_page} 页采集到 {len(articles)} 篇(新增 {new_count} 篇)") # 查找下一页按钮 next_btn = page.query_selector('button.btn-next') if not next_btn: break # 检查是否禁用 is_disabled = next_btn.get_attribute('disabled') if is_disabled is not None: break next_btn.click() page.wait_for_timeout(2000) current_page += 1 return all_articles def get_article_detail(page, url): """获取文章详情""" page.goto(url, wait_until='networkidle', timeout=30000) page.wait_for_timeout(3000) html = page.content() soup = BeautifulSoup(html, 'html.parser') # 标题:在 article > h3 中,格式 "首页 》栏目 》标题" title = '' article_el = soup.find('article') if article_el: h3 = article_el.find('h3') if h3: text = h3.get_text(strip=True) parts = [p.strip() for p in text.split('》')] # 过滤 "首页" 和栏目名,取最后的标题 if len(parts) >= 2: title = parts[-1] else: title = text # 来源和时间 source = '' time_str = '' if article_el: info_p = article_el.find('p', class_='more-info') if info_p: src_el = info_p.find('span', class_='source') if src_el: source = src_el.get_text(strip=True).replace('来源:', '') time_el = info_p.find('span', class_='time') if time_el: time_str = time_el.get_text(strip=True) # 正文 HTML content_div = soup.find('div', class_='article-content') content_html = '' if content_div: content_html = str(content_div) return { 'title': title, 'source': source, 'time': time_str, 'content_html': content_html, } def save_article(article, detail, category_id, cover_path, content_md): """保存文章到数据库""" conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() title = detail['title'] or article['title'] slug = ensure_unique_slug(conn, slugify(title)) # 解析发布时间 created_at = datetime.now().isoformat() if detail.get('time') or article.get('time'): t = detail.get('time') or article.get('time') try: created_at = datetime.strptime(t, '%Y-%m-%d %H:%M').isoformat() except ValueError: try: created_at = datetime.strptime(t, '%Y-%m-%d').isoformat() except ValueError: pass summary = title[:200] if detail.get('source'): summary = f"来源:{detail['source']} | {summary}" cursor.execute(''' INSERT INTO news_posts (title, slug, content, content_type, summary, cover_image, category_id, is_published, is_draft, view_count, created_at, updated_at) VALUES (?, ?, ?, 'markdown', ?, ?, ?, 1, 0, 0, ?, ?) ''', ( title, slug, content_md, summary, cover_path, category_id, created_at, created_at, )) post_id = cursor.lastrowid conn.commit() conn.close() return post_id # ============ 主程序 ============ def main(): print("=" * 60) print("开始批量采集 niaodaifu.cn 文章") print("=" * 60) with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page(viewport={'width': 1920, 'height': 1080}) for menu_id, (category_id, cat_name) in MENU_TO_CATEGORY.items(): print(f"\n>>> 采集栏目 [{cat_name}] menu_id={menu_id} -> category_id={category_id}") articles = get_article_list(page, menu_id) print(f" 共找到 {len(articles)} 篇文章") for idx, article in enumerate(articles, 1): print(f" [{idx}/{len(articles)}] {article['title']}") try: detail = get_article_detail(page, article['href']) except Exception as e: print(f" [详情获取失败] {e}") continue # 下载封面图 cover_path = None if article.get('cover_url'): cover_path, cover_info = download_image(article['cover_url']) if cover_info: conn = sqlite3.connect(DB_PATH) insert_image_to_db(conn, cover_info) conn.commit() conn.close() # 转换正文为 Markdown content_md = '' if detail.get('content_html'): soup = BeautifulSoup(detail['content_html'], 'html.parser') content_div = soup.find('div', class_='article-content') if content_div: downloaded_images = {} content_md = html_to_markdown(content_div, article['href'], downloaded_images) content_md = clean_markdown(content_md) if not content_md.strip(): print(f" [警告] 正文为空,跳过") continue # 保存 try: post_id = save_article(article, detail, category_id, cover_path, content_md) print(f" [已入库] post_id={post_id}") except Exception as e: print(f" [入库失败] {e}") browser.close() print("\n" + "=" * 60) print("采集完成") print("=" * 60) if __name__ == '__main__': main()