443 lines
16 KiB
Python
443 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
文章处理器 - 从数据库读取文章,抓取详情,AI处理,保存结果
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import logging
|
|
import sqlite3
|
|
import requests
|
|
from bs4 import BeautifulSoup
|
|
from urllib.parse import urljoin
|
|
from datetime import datetime
|
|
|
|
# 设置日志
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 导入AI处理器
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from deepseek_processor import AIProcessor
|
|
|
|
# 导入去AI化处理器
|
|
try:
|
|
sys.path.insert(0, '/home/ubuntu/.nanobot/workspace/skills/anti-ai-detector')
|
|
from it_integration import ITContentHumanizer
|
|
HUMANIZER_AVAILABLE = True
|
|
except ImportError:
|
|
HUMANIZER_AVAILABLE = False
|
|
logger.warning("去AI化模块未安装,将跳过人性化处理")
|
|
|
|
|
|
class ArticleProcessor:
|
|
"""文章处理器"""
|
|
|
|
def __init__(self, config_path=None):
|
|
self.config_path = config_path or "/home/ubuntu/nanocode/it_automation_enhanced/config/config.json"
|
|
self.load_config()
|
|
self.ai_processor = AIProcessor(self.config_path)
|
|
|
|
# 初始化去AI化处理器
|
|
if HUMANIZER_AVAILABLE:
|
|
self.humanizer = ITContentHumanizer()
|
|
logger.info("去AI化模块已加载")
|
|
else:
|
|
self.humanizer = None
|
|
|
|
def load_config(self):
|
|
"""加载配置"""
|
|
try:
|
|
with open(self.config_path, 'r', encoding='utf-8') as f:
|
|
self.config = json.load(f)
|
|
logger.info("配置加载成功")
|
|
except Exception as e:
|
|
logger.error(f"配置加载失败: {e}")
|
|
self.config = {}
|
|
|
|
def get_db_connection(self):
|
|
"""获取数据库连接"""
|
|
db_path = self.config.get('paths', {}).get('database_path', 'data/articles.db')
|
|
return sqlite3.connect(db_path)
|
|
|
|
def fetch_article_content(self, url: str, source: str = '') -> str:
|
|
"""抓取文章详情页内容"""
|
|
logger.info(f"抓取文章详情: {url}")
|
|
|
|
headers = {
|
|
'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',
|
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
|
'Accept-Language': 'en-US,en;q=0.5',
|
|
}
|
|
|
|
try:
|
|
response = requests.get(url, headers=headers, timeout=30)
|
|
response.raise_for_status()
|
|
|
|
soup = BeautifulSoup(response.text, 'html.parser')
|
|
|
|
# 根据来源使用不同的提取策略
|
|
if 'wired.com' in url:
|
|
content = self._extract_wired_content(soup)
|
|
elif 'infoq.com' in url or 'infoq.cn' in url:
|
|
content = self._extract_infoq_content(soup)
|
|
elif 'techcrunch.com' in url:
|
|
content = self._extract_techcrunch_content(soup)
|
|
elif 'juejin.cn' in url:
|
|
content = self._extract_juejin_content(soup)
|
|
elif 'oschina.net' in url or 'my.oschina.net' in url:
|
|
content = self._extract_oschina_content(soup)
|
|
elif 'zhihu.com' in url:
|
|
content = self._extract_zhihu_content(soup)
|
|
else:
|
|
# 通用提取
|
|
content = self._extract_generic_content(soup)
|
|
|
|
logger.info(f"抓取成功,内容长度: {len(content)} 字符")
|
|
return content
|
|
|
|
except Exception as e:
|
|
logger.error(f"抓取失败: {e}")
|
|
return ""
|
|
|
|
def _extract_wired_content(self, soup: BeautifulSoup) -> str:
|
|
"""提取Wired文章内容"""
|
|
selectors = [
|
|
'article p',
|
|
'.article-body p',
|
|
'[data-testid="ArticleBodyContent"] p',
|
|
'.body__inner-container p'
|
|
]
|
|
|
|
for selector in selectors:
|
|
paragraphs = soup.select(selector)
|
|
if paragraphs:
|
|
content = '\n\n'.join([p.get_text(strip=True) for p in paragraphs if p.get_text(strip=True)])
|
|
if len(content) > 200:
|
|
return content
|
|
|
|
return ""
|
|
|
|
def _extract_infoq_content(self, soup: BeautifulSoup) -> str:
|
|
"""提取InfoQ文章内容"""
|
|
selectors = [
|
|
'.article__content p',
|
|
'.article-content p',
|
|
'.content__body p',
|
|
'article p'
|
|
]
|
|
|
|
for selector in selectors:
|
|
paragraphs = soup.select(selector)
|
|
if paragraphs:
|
|
content = '\n\n'.join([p.get_text(strip=True) for p in paragraphs if p.get_text(strip=True)])
|
|
if len(content) > 200:
|
|
return content
|
|
|
|
return ""
|
|
|
|
def _extract_techcrunch_content(self, soup: BeautifulSoup) -> str:
|
|
"""提取TechCrunch文章内容"""
|
|
selectors = [
|
|
'.article-content p',
|
|
'.article__body p',
|
|
'.entry-content p',
|
|
'article p'
|
|
]
|
|
|
|
for selector in selectors:
|
|
paragraphs = soup.select(selector)
|
|
if paragraphs:
|
|
content = '\n\n'.join([p.get_text(strip=True) for p in paragraphs if p.get_text(strip=True)])
|
|
if len(content) > 200:
|
|
return content
|
|
|
|
return ""
|
|
|
|
def _extract_juejin_content(self, soup: BeautifulSoup) -> str:
|
|
"""提取掘金文章内容"""
|
|
selectors = [
|
|
'.article-content p',
|
|
'.markdown-body p',
|
|
'.article-content',
|
|
'.markdown-body'
|
|
]
|
|
|
|
for selector in selectors:
|
|
if selector.endswith(' p'):
|
|
paragraphs = soup.select(selector)
|
|
if paragraphs:
|
|
content = '\n\n'.join([p.get_text(strip=True) for p in paragraphs if p.get_text(strip=True)])
|
|
if len(content) > 200:
|
|
return content
|
|
else:
|
|
content_elem = soup.select_one(selector)
|
|
if content_elem:
|
|
text = content_elem.get_text(separator='\n', strip=True)
|
|
if len(text) > 200:
|
|
return text
|
|
|
|
return ""
|
|
|
|
def _extract_oschina_content(self, soup: BeautifulSoup) -> str:
|
|
"""提取OSChina博客内容"""
|
|
selectors = [
|
|
'.article-detail .content p',
|
|
'.blog-content p',
|
|
'.article-content p',
|
|
'.content p'
|
|
]
|
|
|
|
for selector in selectors:
|
|
paragraphs = soup.select(selector)
|
|
if paragraphs:
|
|
content = '\n\n'.join([p.get_text(strip=True) for p in paragraphs if p.get_text(strip=True)])
|
|
if len(content) > 200:
|
|
return content
|
|
|
|
return ""
|
|
|
|
def _extract_zhihu_content(self, soup: BeautifulSoup) -> str:
|
|
"""提取知乎回答内容"""
|
|
selectors = [
|
|
'.RichContent-inner p',
|
|
'.RichContent-inner',
|
|
'.AnswerCard .ContentItem-RichContent p',
|
|
'.ContentItem-RichContent'
|
|
]
|
|
|
|
for selector in selectors:
|
|
if selector.endswith(' p'):
|
|
paragraphs = soup.select(selector)
|
|
if paragraphs:
|
|
content = '\n\n'.join([p.get_text(strip=True) for p in paragraphs if p.get_text(strip=True)])
|
|
if len(content) > 200:
|
|
return content
|
|
else:
|
|
content_elem = soup.select_one(selector)
|
|
if content_elem:
|
|
text = content_elem.get_text(separator='\n', strip=True)
|
|
if len(text) > 200:
|
|
return text
|
|
|
|
return ""
|
|
|
|
def _extract_generic_content(self, soup: BeautifulSoup) -> str:
|
|
"""通用内容提取"""
|
|
# 移除脚本和样式
|
|
for script in soup(['script', 'style', 'nav', 'header', 'footer']):
|
|
script.decompose()
|
|
|
|
# 尝试常见内容选择器
|
|
selectors = [
|
|
'article',
|
|
'.article-content',
|
|
'.content',
|
|
'.post-content',
|
|
'main',
|
|
'.main-content'
|
|
]
|
|
|
|
for selector in selectors:
|
|
content_elem = soup.select_one(selector)
|
|
if content_elem:
|
|
text = content_elem.get_text(separator='\n', strip=True)
|
|
if len(text) > 200:
|
|
return text
|
|
|
|
# 回退到body
|
|
body = soup.find('body')
|
|
if body:
|
|
return body.get_text(separator='\n', strip=True)
|
|
|
|
return ""
|
|
|
|
def process_single_article(self, article_id: int) -> bool:
|
|
"""处理单篇文章"""
|
|
logger.info(f"处理文章 ID: {article_id}")
|
|
|
|
conn = self.get_db_connection()
|
|
cursor = conn.cursor()
|
|
|
|
try:
|
|
# 获取文章信息
|
|
cursor.execute(
|
|
"SELECT id, title, url, source, content FROM articles WHERE id = ?",
|
|
(article_id,)
|
|
)
|
|
row = cursor.fetchone()
|
|
|
|
if not row:
|
|
logger.error(f"文章不存在: {article_id}")
|
|
return False
|
|
|
|
article_id, title, url, source, existing_content = row
|
|
|
|
# 如果数据库中没有内容,抓取详情页
|
|
content = existing_content if existing_content else self.fetch_article_content(url, source)
|
|
|
|
if not content or len(content) < 500:
|
|
logger.warning(f"文章内容太短或为空,跳过: {title}")
|
|
# 标记为已处理(但失败)
|
|
cursor.execute(
|
|
"UPDATE articles SET processed = 1, updated_at = ? WHERE id = ?",
|
|
(datetime.now().isoformat(), article_id)
|
|
)
|
|
conn.commit()
|
|
return False
|
|
|
|
# 更新数据库中的内容
|
|
if not existing_content:
|
|
cursor.execute(
|
|
"UPDATE articles SET content = ? WHERE id = ?",
|
|
(content, article_id)
|
|
)
|
|
conn.commit()
|
|
|
|
# AI处理:判断是否需要翻译(国内源已经是中文)
|
|
is_chinese_source = source in ['掘金', 'OSChina', '知乎热榜'] or 'juejin' in url or 'oschina' in url or 'zhihu' in url
|
|
|
|
if is_chinese_source:
|
|
# 中文源:改写润色 + 总结
|
|
logger.info("中文文章,进行改写润色...")
|
|
rewritten = self.ai_processor.rewrite_article(content)
|
|
|
|
if not rewritten:
|
|
logger.warning("改写失败,使用原文")
|
|
rewritten = content
|
|
|
|
# 去AI化处理(改写后)
|
|
if self.humanizer:
|
|
logger.info("开始去AI化处理(改写)...")
|
|
rewritten = self.humanizer.process_translation(rewritten)
|
|
|
|
logger.info("开始AI总结...")
|
|
summary = self.ai_processor.summarize_article(content)
|
|
|
|
# 去AI化处理(总结后)
|
|
if self.humanizer:
|
|
logger.info("开始去AI化处理(总结)...")
|
|
summary = self.humanizer.process_summary(summary, platform='xiaohongshu')
|
|
|
|
translated = rewritten
|
|
else:
|
|
# 英文源:翻译 + 总结(原有逻辑)
|
|
logger.info("开始AI翻译...")
|
|
translated = self.ai_processor.translate_article(content)
|
|
|
|
if not translated:
|
|
logger.error("翻译失败")
|
|
return False
|
|
|
|
# 去AI化处理(翻译后)
|
|
if self.humanizer:
|
|
logger.info("开始去AI化处理(翻译)...")
|
|
translated = self.humanizer.process_translation(translated)
|
|
|
|
logger.info("开始AI总结...")
|
|
summary = self.ai_processor.summarize_article(content)
|
|
|
|
# 去AI化处理(总结后)
|
|
if self.humanizer:
|
|
logger.info("开始去AI化处理(总结)...")
|
|
summary = self.humanizer.process_summary(summary, platform='xiaohongshu')
|
|
|
|
# 保存处理结果到文件(供邮件和小红书使用)
|
|
output_dir = self.config.get('paths', {}).get('output_dir', 'output')
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
|
|
result = {
|
|
'id': article_id,
|
|
'original_title': title,
|
|
'url': url,
|
|
'source': source,
|
|
'translated_content': translated,
|
|
'summary': summary,
|
|
'processed_at': datetime.now().isoformat()
|
|
}
|
|
|
|
output_file = os.path.join(output_dir, f"article_{article_id}_processed.json")
|
|
with open(output_file, 'w', encoding='utf-8') as f:
|
|
json.dump(result, f, ensure_ascii=False, indent=2)
|
|
|
|
logger.info(f"处理结果保存到: {output_file}")
|
|
|
|
# 更新数据库状态
|
|
cursor.execute(
|
|
"""UPDATE articles
|
|
SET processed = 1,
|
|
content = ?,
|
|
updated_at = ?
|
|
WHERE id = ?""",
|
|
(content, datetime.now().isoformat(), article_id)
|
|
)
|
|
conn.commit()
|
|
|
|
logger.info(f"文章处理完成: {title}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"处理文章失败: {e}")
|
|
return False
|
|
finally:
|
|
conn.close()
|
|
|
|
def process_unprocessed_articles(self, limit: int = 5) -> list:
|
|
"""处理所有未处理的文章"""
|
|
logger.info("获取未处理文章列表...")
|
|
|
|
conn = self.get_db_connection()
|
|
cursor = conn.cursor()
|
|
|
|
cursor.execute(
|
|
"""SELECT id, title, url, source FROM articles
|
|
WHERE processed = 0
|
|
ORDER BY created_at DESC
|
|
LIMIT ?""",
|
|
(limit,)
|
|
)
|
|
|
|
articles = cursor.fetchall()
|
|
conn.close()
|
|
|
|
logger.info(f"找到 {len(articles)} 篇未处理文章")
|
|
|
|
processed_ids = []
|
|
for row in articles:
|
|
article_id = row[0]
|
|
if self.process_single_article(article_id):
|
|
processed_ids.append(article_id)
|
|
|
|
return processed_ids
|
|
|
|
|
|
def main():
|
|
"""主函数"""
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(description='处理文章')
|
|
parser.add_argument('--article-id', type=int, help='处理指定ID的文章')
|
|
parser.add_argument('--limit', type=int, default=5, help='最多处理多少篇文章')
|
|
parser.add_argument('--all', action='store_true', help='处理所有未处理文章')
|
|
|
|
args = parser.parse_args()
|
|
|
|
processor = ArticleProcessor()
|
|
|
|
if args.article_id:
|
|
success = processor.process_single_article(args.article_id)
|
|
sys.exit(0 if success else 1)
|
|
else:
|
|
limit = args.limit if not args.all else 100
|
|
processed = processor.process_unprocessed_articles(limit)
|
|
logger.info(f"成功处理 {len(processed)} 篇文章")
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|