Files

2713 lines
108 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
cn-financial-scraper MCP Server — 中国金融数据爬取与研究工具
通过 MCP 协议暴露机构查询、网页爬取、产品解析、公告下载、新闻资讯等工具,
让 Claude Code 可以直接调用中国金融数据爬虫的核心功能。
使用方式(Claude Code 配置 mcpServers):
{
"mcpServers": {
"cn-financial-scraper": {
"command": "python",
"args": ["./mcp_server.py"],
"env": {
"PYTHONDONTWRITEBYTECODE": "1"
}
}
}
}
"""
from __future__ import annotations
import sys
sys.dont_write_bytecode = True
import json
import os
import logging
import asyncio
from datetime import datetime
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR / "scripts"))
try:
from mcp.server.fastmcp import FastMCP
except ImportError as e:
print(f"❌ MCP SDK 不可用: {e}", file=sys.stderr)
print("请运行: pip install mcp", file=sys.stderr)
sys.exit(1)
# 顶层导入核心模块(避免每次工具调用重复 import)
try:
from scrapable_registry import ScrapableRegistry
except ImportError:
ScrapableRegistry = None
try:
from scraper import FinancialPageScraper
except ImportError:
FinancialPageScraper = None
try:
from institution_scraper import InstitutionScraper
except ImportError:
InstitutionScraper = None
try:
from news_scraper import EastMoneyNewsAPI
except ImportError:
EastMoneyNewsAPI = None
try:
from announcement_scraper import AnnouncementManager, PDFDownloader
except ImportError:
AnnouncementManager = PDFDownloader = None
try:
from research_report_scraper import BrokerReportManager
except ImportError:
BrokerReportManager = None
try:
from comprehensive_report_scraper import ComprehensiveReportManager
except ImportError:
ComprehensiveReportManager = None
try:
from document_parser import parse_document as doc_parse
except ImportError:
doc_parse = None
try:
from report_exporter import ComprehensiveExporter
except ImportError:
ComprehensiveExporter = None
try:
from batch_institution_crawler import BatchInstitutionCrawler
except ImportError:
BatchInstitutionCrawler = None
try:
from report_indexer import StockIndexer
except ImportError:
StockIndexer = None
try:
from document_analyzer import DocumentAnalyzer
except ImportError:
DocumentAnalyzer = None
# v3.0 新增数据源
try:
from sina_scraper import get_realtime_quote as sina_quote
except ImportError:
sina_quote = None
try:
from cls_scraper import get_telegraph as cls_telegraph
except ImportError:
cls_telegraph = None
try:
from jisilu_scraper import get_convertible_bonds as jisilu_bonds, search_bonds as jisilu_search
except ImportError:
jisilu_bonds = jisilu_search = None
# v4.0 新增模块
try:
from crawl_scheduler import CrawlScheduler, get_scheduler
except ImportError:
CrawlScheduler = get_scheduler = None
try:
from crawl_packager import batch_crawl_and_package as _batch_crawl_and_package
except ImportError:
_batch_crawl_and_package = None
try:
from content_compressor import compress_content, CompressConfig
except ImportError:
compress_content = CompressConfig = None
try:
from enhanced_parser import parse_file_enhanced as _parse_file_enhanced
except ImportError:
_parse_file_enhanced = None
try:
from research_report_generator import ResearchReportGenerator, ReportConfig, ReportTheme
except ImportError:
ResearchReportGenerator = ReportConfig = ReportTheme = None
# v4.3 全网舆情爬虫
try:
from sentiment_crawler import (
SentimentCrawler, SentimentSourceLoader, SentimentTargetLoader,
crawl_sentiment as _crawl_sentiment,
list_sentiment_targets as _list_sentiment_targets,
list_sentiment_sources as _list_sentiment_sources,
add_custom_sentiment_target as _add_custom_target,
)
except ImportError:
SentimentCrawler = SentimentSourceLoader = SentimentTargetLoader = None
_crawl_sentiment = _list_sentiment_targets = _list_sentiment_sources = _add_custom_target = None
try:
from sentiment_exporter import export as _export_sentiment
except ImportError:
_export_sentiment = None
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("mcp_server")
server = FastMCP("cn-financial-scraper")
def _module_error(module_name: str, install_hint: str = "") -> str:
"""生成模块缺失的错误信息(含安装指引)"""
hint = install_hint or f"pip install -r requirements.txt"
return f"❌ {module_name} 模块未安装,请运行: {hint}"
# ==================== 机构名单查询 ====================
@server.tool()
async def query_institution(institution_type: str = "", keyword: str = "") -> str:
"""查询中国金融机构名单。支持按类型筛选和关键词搜索。
机构类型(27大类): 基金管理公司, 证券公司, 保险公司, 信托公司, 商业银行,
私募基金, 外资金融机构, 金融租赁, 第三方销售, 理财公司, 期货公司,
消费金融公司, 保险资产管理公司, 再保险公司, 汽车金融公司, 金融控股公司,
货币经纪公司, 金融资产投资公司, 企业集团财务公司, 融资担保公司,
期货风险管理子公司, 城投机构等。
Args:
institution_type: 机构类型(留空查询全部)
keyword: 搜索关键词,如"华夏"、"招商"(可选)
"""
try:
if ScrapableRegistry is None:
return _module_error("scrapable_registry")
registry = ScrapableRegistry()
if keyword:
results = registry.search(keyword)
if not results:
return f"未找到包含「{keyword}」的机构。"
lines = [f"🔍 搜索「{keyword}」结果 ({len(results)}条):", ""]
for r in results[:30]:
scrapable = r.get('scrapable', False)
website = r.get('website', '') or ''
lines.append(
f" {r['name']} [{r['type']}] "
f"{'✅ ' + website if scrapable and website else '❌ 无URL'}"
)
return '\n'.join(lines)
stats = registry.get_statistics()
lines = [
f"📊 全量金融机构统计 ({stats['total']}家)",
f"可爬取: {stats['scrapable']}家",
"",
"按类型统计:",
]
for t, info in sorted(stats.get('by_type', {}).items(), key=lambda x: -x[1]['total']):
lines.append(f" {t}: {info['total']}家 ({info['scrapable']}可爬取)")
if institution_type:
insts = registry.list_by_type(institution_type)
lines.extend(["", f"「{institution_type}」列表 ({len(insts)}家):"])
for i in insts[:20]:
website = i.get('website', '') or ''
scrapable = i.get('scrapable', False)
lines.append(
f" {i['name']} {'✅ ' + website if scrapable and website else ''}"
)
if len(insts) > 20:
lines.append(f" ... 还有 {len(insts)-20} 家")
return '\n'.join(lines)
except Exception as e:
return f"查询失败: {str(e)}"
# ==================== 网页爬取 ====================
@server.tool()
async def scrape_webpage(url: str, use_dynamic: bool = False) -> str:
"""爬取指定URL的网页内容,返回文字摘要。
支持静态页面和动态渲染(JavaScript)两种模式。
适用场景:爬取金融机构官网、新闻页面、产品页面等。
Args:
url: 完整网页URL
use_dynamic: 是否使用动态渲染(处理JS加载的页面),默认False
"""
try:
if FinancialPageScraper is None:
return _module_error("scraper")
scraper = FinancialPageScraper()
content = scraper.scrape_url(url, use_dynamic=use_dynamic)
if content:
text = str(content)[:3000]
return f"✅ 成功获取 {len(text)} 字符\n\n{text}"
return f"❌ 爬取失败,目标网站可能有限流或需要登录。"
except Exception as e:
return f"爬取失败: {str(e)}"
@server.tool()
async def scrape_institution(name: str) -> str:
"""按机构名称爬取其官网内容。自动从注册表中查找URL并爬取。
适用场景:需要快速获取某金融机构的官网最新信息。
Args:
name: 机构名称关键词,如"华夏基金"、"招商证券"
"""
try:
if InstitutionScraper is None:
return _module_error("institution_scraper")
scraper = InstitutionScraper()
result = scraper.scrape_by_name(name)
if result and result.get('content'):
text = result['content'][:2000]
return (
f"✅ {result.get('name', name)} 爬取成功\n"
f"来源: {result.get('url', '未知')}\n"
f"内容:\n{text}"
)
return f"❌ 未找到机构「{name}」或爬取失败。"
except Exception as e:
return f"爬取失败: {str(e)}"
# ==================== 金融产品解析 ====================
@server.tool()
async def parse_financial_product(url: str, product_type: str = "fund") -> str:
"""解析金融产品页面信息。支持基金、ETF、FOF、股票、债券等。
适用场景:需要提取某只基金/股票/债券的详细产品信息。
Args:
url: 产品页面URL,如天天基金、东方财富产品页
product_type: 产品类型 — fund(基金) / etf(ETF) / fof(FOF) / stock(股票) / bond(债券)
"""
try:
# 动态导入 web_parser
import importlib
web_parser = importlib.import_module("web_parser")
result = web_parser.parse_financial_product(url, product_type)
if result:
text = json.dumps(result, ensure_ascii=False, indent=2)[:2500]
return f"✅ {product_type.upper()} 解析成功\n\n{text}"
return f"❌ 解析失败,请检查URL或产品类型。"
except Exception as e:
return f"解析失败: {str(e)}"
# ==================== 新闻资讯 ====================
@server.tool()
async def crawl_financial_news(limit: int = 10) -> str:
"""爬取最新金融新闻资讯。来源:东方财富、同花顺。
适用场景:了解最新金融动态、市场热点、政策变化。
Args:
limit: 返回条数,默认10,最多30
"""
try:
if EastMoneyNewsAPI is None:
return _module_error("news_scraper")
scraper = EastMoneyNewsAPI()
# v4.4.0 修复:fetch_latest() 不存在,改用 get_market_news()
try:
news = scraper.get_market_news(limit=min(limit, 30))
except Exception:
news = scraper.get_stock_news(limit=min(limit, 30))
if not news:
return "暂无新闻数据。"
lines = [f"📰 最新金融新闻 ({len(news)}条)", ""]
for i, item in enumerate(news, 1):
title = item.get('title', '无标题')
source = item.get('source', '')
date = item.get('date', '')
lines.append(f" {i:>2}. [{source}] {title} ({date})")
return '\n'.join(lines)
except Exception as e:
return f"新闻爬取失败: {str(e)}"
# ==================== 公告搜索与下载 ====================
@server.tool()
async def search_announcements(keyword: str, limit: int = 10) -> str:
"""搜索上市公司公告。覆盖沪深两市上市公司公告。
适用场景:查询某公司或某关键词的最新公告。
Args:
keyword: 搜索关键词,如"华夏基金"、"分红"、"业绩预告"
limit: 返回条数,默认10
"""
try:
if AnnouncementManager is None:
return _module_error("announcement_scraper")
scraper = AnnouncementManager()
announcements = scraper.search(keyword, limit=min(limit, 20))
if not announcements:
return f"未找到包含「{keyword}」的公告。"
lines = [f"📋 公告搜索结果「{keyword}」({len(announcements)}条)", ""]
for i, ann in enumerate(announcements, 1):
lines.append(
f" {i:>2}. {ann.get('title', '无标题')} "
f"{ann.get('date', '')} {ann.get('code', '')}"
)
lines.append("")
lines.append("提示: 需要下载PDF可使用 download_announcement 工具")
return '\n'.join(lines)
except Exception as e:
return f"公告搜索失败: {str(e)}"
@server.tool()
async def download_announcement(url: str, save_dir: str = "") -> str:
"""下载公告PDF文件到本地。
需先通过 search_announcements 获取公告URL。
Args:
url: 公告PDF的下载链接
save_dir: 保存目录(可选,默认保存到 data/announcements/)
"""
try:
if PDFDownloader is None:
return _module_error("announcement_scraper")
scraper = PDFDownloader()
output_dir = save_dir or str(SCRIPT_DIR / "data" / "announcements")
os.makedirs(output_dir, exist_ok=True)
path = scraper.download(url, save_dir=output_dir)
return f"✅ PDF已下载到: {path}"
except Exception as e:
return f"下载失败: {str(e)}"
# ==================== 券商研报查询 ====================
@server.tool()
async def query_broker_reports(stock_code: str = "", broker_name: str = "") -> str:
"""查询券商研究报告。支持按股票代码或券商名称筛选。
适用场景:查询个股的最新券商研报评级、目标价、分析观点。
Args:
stock_code: 6位股票代码,如 600519(可选)
broker_name: 券商名称,如"中金公司"、"中信证券"(可选)
"""
try:
if BrokerReportManager is None:
return _module_error("research_report_scraper")
manager = BrokerReportManager()
if stock_code:
result = manager.generate_report_summary(stock_code)
if result:
return f"【{stock_code} 券商研报】\n\n{result[:3000]}"
return f"未找到 {stock_code} 的券商研报。"
if broker_name:
reports = manager.get_reports_by_broker(broker_name)
if reports:
lines = [f"📊 {broker_name} 研报 ({len(reports)}条)", ""]
for r in reports[:15]:
lines.append(
f" {r.get('stock_code', '')} {r.get('stock_name', '')} "
f"评级:{r.get('rating', '')} 目标价:{r.get('target_price', '?')}"
)
return '\n'.join(lines)
return f"未找到 {broker_name} 的研报。"
return "请提供 stock_code 或 broker_name 参数。"
except Exception as e:
return f"查询失败: {str(e)}"
# ==================== 综合报告查询 ====================
@server.tool()
async def get_company_reports(stock_code: str) -> str:
"""获取上市公司综合报告(年报/半年报/季报+券商研报)。
适用场景:一站式查询某只股票的定期报告和最新研报。
Args:
stock_code: 6位股票代码
"""
try:
if ComprehensiveReportManager is None:
return _module_error("comprehensive_report_scraper")
manager = ComprehensiveReportManager()
data = manager.get_all_reports(stock_code)
if not data:
return f"未获取到 {stock_code} 的报告数据。"
summary = manager.generate_report_summary(stock_code)
return f"【{stock_code} 综合报告】\n\n{summary[:3000]}"
except Exception as e:
return f"查询失败: {str(e)}"
# ==================== 文档解析 ====================
@server.tool()
async def parse_document(file_path: str) -> str:
"""解析金融文档内容(PDF/Word/Excel)。
适用场景:读取金融PDF研报、Word报告、Excel数据表格。
Args:
file_path: 文档完整路径
"""
try:
if doc_parse is None:
return _module_error("document_parser")
ext = Path(file_path).suffix.lower()
supported = {'.pdf', '.docx', '.doc', '.xlsx', '.xls', '.txt', '.md', '.csv'}
if ext not in supported:
return f"不支持的文件格式: {ext}。支持: {', '.join(sorted(supported))}"
result = doc_parse(file_path)
if isinstance(result, dict) and result.get("error"):
return f"❌ {result['error']}"
text = result.get("text_content", "") if isinstance(result, dict) else str(result)
if text:
content = text[:3000]
return f"✅ 解析成功 ({len(text)}字符)\n\n{content}"
return "❌ 解析失败,文件可能已损坏或加密。"
except Exception as e:
return f"解析失败: {str(e)}"
# ==================== 研报导出 ====================
@server.tool()
async def export_stock_report(stock_code: str, formats: str = "xlsx") -> str:
"""导出上市公司研究报告。支持PPT/PDF/Word/Excel多种格式。
适用场景:将股票研究数据导出为可打印或可编辑的文档。
Args:
stock_code: 6位股票代码
formats: 导出格式,逗号分隔,如"ppt,docx,xlsx"(默认xlsx)
"""
try:
if ComprehensiveExporter is None or ComprehensiveReportManager is None:
return _module_error("report_exporter 或 comprehensive_report_scraper")
manager = ComprehensiveReportManager()
data = manager.get_all_reports(stock_code)
if not data:
return f"未获取到 {stock_code} 的报告数据。"
exporter = ComprehensiveExporter()
fmt_list = [f.strip() for f in formats.split(",")]
output = str(SCRIPT_DIR / "data" / "reports")
os.makedirs(output, exist_ok=True)
lines = [f"📁 导出 {stock_code} 报告", ""]
for fmt in fmt_list:
try:
if fmt == 'ppt':
from report_exporter import PPTExporter
path = PPTExporter().export_comprehensive_report(data, stock_code, output)
elif fmt == 'docx':
from report_exporter import WordExporter
path = WordExporter().export_comprehensive_report(data, stock_code, output)
elif fmt == 'xlsx':
from report_exporter import ExcelExporter
path = ExcelExporter().export_comprehensive_report(data, stock_code, output)
elif fmt == 'pdf':
from report_exporter import PDFExporter
path = PDFExporter().export_comprehensive_report(data, stock_code, output)
else:
lines.append(f" ❌ 不支持格式: {fmt},支持: ppt, docx, xlsx, pdf")
continue
lines.append(f" ✅ .{fmt} -> {path}")
except Exception as e:
lines.append(f" ❌ .{fmt} 导出失败: {str(e)}")
return '\n'.join(lines)
except ImportError as e:
return f"缺少导出依赖库: {e}。请安装: python-pptx / reportlab / openpyxl"
except Exception as e:
return f"导出失败: {str(e)}"
# ==================== 批量爬取 ====================
@server.tool()
async def batch_crawl_institutions(names: str = "",
institution_type: str = "") -> str:
"""批量爬取金融机构信息。支持按名称列表或机构类型批量操作。
适用场景:需要同时获取多家机构的官网最新信息。
Args:
names: 机构名称列表,逗号分隔,如"易方达基金,华夏基金,广发基金"
institution_type: 机构类型,如"基金管理公司"、"证券公司"(与names二选一)
"""
try:
if BatchInstitutionCrawler is None:
return _module_error("batch_institution_crawler")
crawler = BatchInstitutionCrawler()
if names:
inst_list = [{"name": n.strip()} for n in names.split(",")]
results = crawler.crawl_by_names(inst_list)
elif institution_type:
results = crawler.crawl_by_type(institution_type)
else:
return "请提供 names 或 institution_type 参数。"
lines = [f"📊 批量爬取完成 ({len(results)}家)", ""]
for r in results[:20]:
name = r.get('name', '?')
status = '✅' if r.get('success') else '❌'
lines.append(f" {status} {name}")
if len(results) > 20:
lines.append(f" ... 还有 {len(results)-20} 家")
return '\n'.join(lines)
except Exception as e:
return f"批量爬取失败: {str(e)}"
# ==================== 全量索引查询 ====================
@server.tool()
async def search_report_index(keyword: str, report_type: str = "") -> str:
"""在全量报告索引中搜索,支持按类型筛选。
适用场景:搜索全市场股票报告中包含某关键词的条目。
Args:
keyword: 搜索关键词,如"分红"、"回购"、"业绩预增"
report_type: 报告类型 — periodic(定期报告) / broker(券商研报)(可选)
"""
try:
if StockIndexer is None:
return _module_error("report_indexer")
indexer = StockIndexer()
results = indexer.search_reports(
keyword,
report_type=report_type if report_type else None
)
if not results:
return f"索引中未找到包含「{keyword}」的报告。"
lines = [f"🔍 索引搜索「{keyword}」({len(results)}条)", ""]
for r in results[:15]:
lines.append(
f" {r.get('code', '')} {r.get('name', '')} "
f"{r.get('title', '')} {r.get('date', '')}"
)
if len(results) > 15:
lines.append(f" ... 还有 {len(results)-15} 条")
lines.append("")
lines.append("提示: 使用 get_company_reports 查看具体报告内容")
return '\n'.join(lines)
except Exception as e:
return f"搜索失败: {str(e)}"
# ==================== 文档分析整理 ====================
@server.tool()
async def analyze_document(file_path: str, focus: str = "") -> str:
"""深度分析金融文档。自动分类(年报/研报/公告等),提取元数据(标题/机构/日期/股票代码)、
章节结构、财务指标、风险因素、术语表,生成结构化 Markdown 报告。
适用场景:分析单份 PDF/Word/Excel 金融文档,提取关键信息。
Args:
file_path: 文档完整路径
focus: 重点关注维度(可选):财务指标 / 风险 / 章节 / all(默认全部)
"""
try:
if DocumentAnalyzer is None:
return _module_error("document_analyzer")
analyzer = DocumentAnalyzer()
result = analyzer.analyze(file_path)
if result.get("error"):
return f"❌ 分析失败: {result['error']}"
# 根据 focus 剪裁输出
if focus == "财务指标" or focus == "指标":
indicators = result.get("financial_indicators", {})
if not indicators:
return "未提取到财务指标。"
lines = [f"【{Path(file_path).name} 财务指标】", ""]
for k, v in indicators.items():
if k not in ("amounts_yi", "total_yi", "percentages"):
lines.append(f" {k}: {v}")
return '\n'.join(lines)
elif focus == "风险":
risks = result.get("risk_factors", [])
if not risks:
return "未提取到风险因素。"
lines = [f"【{Path(file_path).name} 风险因素】({len(risks)}条)", ""]
for i, r in enumerate(risks, 1):
lines.append(f" {i}. {r}")
return '\n'.join(lines)
elif focus == "章节":
sections = result.get("sections", [])
if not sections:
return "未识别到章节结构。"
lines = [f"【{Path(file_path).name} 章节结构】({len(sections)}个)", ""]
for sec in sections[:25]:
indent = " " * (sec.get("level", 1) - 1)
lines.append(f"{indent}{'#' * sec.get('level', 1)} {sec['title']}")
return '\n'.join(lines)
# 默认输出完整 Markdown
return analyzer.to_markdown(result)
except Exception as e:
return f"分析失败: {str(e)}"
@server.tool()
async def organize_documents(dir_path: str) -> str:
"""批量整理文档目录。扫描指定目录下所有 PDF/Word/Excel 文件,
逐份分析分类后按类别聚合,生成索引文件(JSON)和汇总报告(Markdown)。
适用场景:整理一个文件夹中的研报、财报、公告等金融文档。
Args:
dir_path: 文档目录路径
"""
try:
if DocumentAnalyzer is None:
return _module_error("document_analyzer")
analyzer = DocumentAnalyzer()
result = analyzer.organize_directory(dir_path)
if result.get("error"):
return f"❌ 整理失败: {result['error']}"
lines = [
f"📁 文档目录整理完成",
f" 来源: {dir_path}",
f" 文档总数: {result['total']}",
f" 类别数: {len(result.get('categories', {}))}",
"",
"【按类别统计】",
]
for cat, count in sorted(result.get("categories", {}).items(), key=lambda x: -x[1]):
lines.append(f" {cat}: {count}份")
lines.append("")
lines.append(f"📄 索引文件: {result.get('index_file', '')}")
lines.append(f"📝 汇总报告: {result.get('summary_file', '')}")
return '\n'.join(lines)
except Exception as e:
return f"整理失败: {str(e)}"
@server.tool()
async def compare_documents(file_paths: str) -> str:
"""并排对比多份金融文档。提取相同维度(类型/日期/机构/关键指标等),
高亮差异,适合对比多家公司的年报或不同券商的研报。
Args:
file_paths: 文档路径列表,逗号分隔,如 "report_a.pdf,report_b.pdf"
"""
try:
if DocumentAnalyzer is None:
return _module_error("document_analyzer")
paths = [p.strip() for p in file_paths.split(",") if p.strip()]
if len(paths) < 2:
return "请提供至少2份文档路径(逗号分隔)。"
analyzer = DocumentAnalyzer()
result = analyzer.compare(paths)
lines = [
f"📊 文档对比 ({result['compared']}份)",
"",
"【各文档概览】",
]
for f in result.get("files", []):
lines.append(f" 📄 {f['name']} — {f['category']} | {f['summary'][:80]}...")
lines.append("\n【维度对比】")
for dim, values in result.get("dimensions", {}).items():
if dim == "关键指标":
continue
lines.append(f" {dim}: {' | '.join(str(v) for v in values)}")
differences = result.get("differences", [])
if differences:
lines.append(f"\n【⚠️ 发现 {len(differences)} 处差异】")
for diff in differences:
lines.append(f" • {diff}")
else:
lines.append(f"\n✅ 未发现明显差异")
# 关键指标对比
indicators = result.get("dimensions", {}).get("关键指标", [])
if indicators and len(indicators) >= 2:
lines.append("\n【关键财务指标对比】")
all_keys = set()
for ind in indicators:
all_keys.update(k for k in ind.keys() if k not in ("amounts_yi", "total_yi", "percentages"))
if all_keys:
for key in sorted(all_keys):
vals = [str(ind.get(key, "-")) for ind in indicators]
lines.append(f" {key}: {' | '.join(vals)}")
return '\n'.join(lines)
except Exception as e:
return f"对比失败: {str(e)}"
# ==================== 实时行情 (v3.0 新增) ====================
@server.tool()
async def get_stock_realtime(stock_codes: str) -> str:
"""获取A股实时行情。数据来源:新浪财经。
适用场景:查询个股实时价格、涨跌幅、成交量、成交额等。
Args:
stock_codes: 股票代码,多个用逗号分隔,如 "600519,000858,300750"
"""
try:
if sina_quote is None:
return _module_error("sina_scraper")
codes = [c.strip() for c in stock_codes.split(",") if c.strip()]
if not codes:
return "请提供至少一个股票代码。"
data = sina_quote(codes)
if not data:
return "未获取到行情数据,请检查股票代码是否正确。"
lines = [f"📊 实时行情 ({len(data)}只)", ""]
for code, info in data.items():
lines.append(
f" {info.get('name', '?')} ({code}) "
f"价格:{info.get('price', '?')} "
f"涨跌:{info.get('change_pct', 0):+.2f}% "
f"成交:{info.get('amount', 0):.0f}万"
)
return '\n'.join(lines)
except Exception as e:
return f"行情查询失败: {str(e)}"
# ==================== 基金净值历史 (v3.0 新增) ====================
@server.tool()
async def get_fund_nav_history(fund_code: str, days: int = 30) -> str:
"""获取基金历史净值数据。数据来源:天天基金。
适用场景:查看基金近期净值走势、计算收益率。
Args:
fund_code: 6位基金代码,如 000001
days: 查询天数,默认30天
"""
try:
url = f"https://fund.eastmoney.com/f10/F10DataApi.aspx?type=lsjz&code={fund_code}&page=1&per={min(days, 60)}"
from http_utils import fetch_text
text = fetch_text(url)
if not text:
return f"未获取到基金 {fund_code} 的净值数据。"
import re
records = re.findall(r'<tr[^>]*>.*?<td[^>]*>(\d{4}-\d{2}-\d{2})</td>.*?<td[^>]*class="tor[^"]*">([\d.]+)</td>.*?<td[^>]*class="tor[^"]*">([\d.]+)</td>', text, re.DOTALL)
if not records:
return f"未获取到基金 {fund_code} 的净值历史。"
lines = [f"📈 基金 {fund_code} 历史净值 (近{days}天, 共{len(records)}条)", ""]
for date, nav, acc_nav in records[:20]:
lines.append(f" {date} 单位净值:{nav} 累计净值:{acc_nav}")
if len(records) > 20:
lines.append(f" ... 还有 {len(records)-20} 条")
return '\n'.join(lines)
except Exception as e:
return f"净值查询失败: {str(e)}"
# ==================== 财联社电报 (v3.0 新增) ====================
@server.tool()
async def crawl_cls_telegraph(limit: int = 20) -> str:
"""获取财联社7x24小时电报快讯。数据来源:财联社(cls.cn)。
适用场景:获取最新市场快讯、政策消息、公司公告速递。
Args:
limit: 返回条数,默认20,最多50
"""
try:
if cls_telegraph is None:
return _module_error("cls_scraper")
items = cls_telegraph(limit=min(limit, 50))
if not items:
return "暂无财联社电报数据。"
lines = [f"📡 财联社7x24电报 ({len(items)}条)", ""]
for i, item in enumerate(items, 1):
level_icon = {"A": "🔴", "B": "🟡", "C": "🟢"}.get(item.get("level", "C"), "⚪")
title = (item.get("title", "") or "")[:100]
lines.append(f" {i:>2}. {level_icon} {title}")
return '\n'.join(lines)
except Exception as e:
return f"电报获取失败: {str(e)}"
# ==================== 可转债数据 (v3.0 新增) ====================
@server.tool()
async def get_convertible_bond_data(bond_code: str = "") -> str:
"""获取可转债数据(转股价/溢价率/到期收益率/评级等)。
数据来源:集思录(jisilu.cn)。
适用场景:可转债投资分析,筛选低溢价率标的。
Args:
bond_code: 可转债代码(可选,留空返回全量列表)
"""
try:
if jisilu_bonds is None:
return _module_error("jisilu_scraper")
if bond_code:
bonds = jisilu_search(bond_code)
else:
bonds = jisilu_bonds(listed_only=True)
if not bonds:
return "未获取到可转债数据,请检查集思录是否可访问。"
lines = [f"📊 可转债数据 ({len(bonds)}只)", ""]
lines.append(f" {'代码':<8} {'名称':<12} {'现价':>6} {'转股价':>6} {'溢价率':>6}")
lines.append(" " + "-" * 50)
for b in bonds[:20]:
lines.append(
f" {b.get('bond_id', '?'):<8} {str(b.get('bond_nm', '?'))[:12]:<12} "
f"{float(b.get('price', 0)):>6.2f} {float(b.get('convert_price', 0)):>6.2f} "
f"{float(b.get('premium_rt', 0)):>5.1f}%"
)
if len(bonds) > 20:
lines.append(f" ... 还有 {len(bonds)-20} 只")
return '\n'.join(lines)
except Exception as e:
return f"可转债查询失败: {str(e)}"
# ==================== v4.0 定期自动爬取 ====================
@server.tool()
async def schedule_crawl_task(name: str, frequency: str = "daily",
urls: str = "", keywords: str = "",
institution_type: str = "",
action: str = "crawl_and_compress",
focus: str = "全面",
max_runs: int = 0,
sentiment_targets: str = "",
sentiment_categories: str = "",
sentiment_source_categories: str = "",
sentiment_days: int = 7,
sentiment_positive_only: bool = False,
sentiment_negative_only: bool = False,
sentiment_export_format: str = "all") -> str:
"""创建定期自动爬取任务。任务将在后台按指定频率自动执行。
支持两种模式:
A. 常规爬取(默认值 action="crawl_and_compress")— 同 v4.0
B. 全网舆情爬虫 — action 传 "crawl_sentiment" 或 "crawl_sentiment_export"
模式 B 额外参数:
sentiment_targets: 单/多个机构(逗号分隔),如"贵州茅台,工银瑞信基金"
sentiment_categories: 目标类别(fund_company/listed_company/...)
sentiment_source_categories: 媒体类别(authoritative/self_media/...)
sentiment_days: 时间窗口(天,默认7)
sentiment_positive_only: 仅保留正面
sentiment_negative_only: 仅保留舆情
sentiment_export_format: 导出格式 dialog/word/excel/csv/json/all/auto
Args:
name: 任务名称,如"每日茅台舆情"
frequency: 执行频率 — every_5_minutes / hourly / every_6_hours / daily / weekly / monthly / custom_cron
urls: 目标URL列表,逗号分隔(可选)
keywords: 搜索关键词,逗号分隔(可选)
institution_type: 机构类型(可选)
action: 执行动作 — crawl_only / crawl_and_compress / crawl_and_package / crawl_compress_package / crawl_sentiment / crawl_sentiment_export
focus: 压缩关注维度 — 财务/风险/行业/政策/事件/全面
max_runs: 最大执行次数,0表示无限
"""
try:
if get_scheduler is None:
return _module_error("crawl_scheduler")
scheduler = get_scheduler()
url_list = [u.strip() for u in urls.split(",") if u.strip()] if urls else []
kw_list = [k.strip() for k in keywords.split(",") if k.strip()] if keywords else []
inst_types = [institution_type] if institution_type else []
st_list = [t.strip() for t in sentiment_targets.split(",") if t.strip()] if sentiment_targets else []
sc_list = [c.strip() for c in sentiment_categories.split(",") if c.strip()] if sentiment_categories else []
ssc_list = [c.strip() for c in sentiment_source_categories.split(",") if c.strip()] if sentiment_source_categories else []
task = scheduler.create_task(
name=name, frequency=frequency,
target_urls=url_list, target_keywords=kw_list,
target_institution_types=inst_types,
action=action, focus_dimension=focus,
max_runs=max_runs,
sentiment_targets=st_list,
sentiment_categories=sc_list,
sentiment_source_categories=ssc_list,
sentiment_days=sentiment_days,
sentiment_positive_only=sentiment_positive_only,
sentiment_negative_only=sentiment_negative_only,
sentiment_export_format=sentiment_export_format,
)
if not scheduler.is_running():
scheduler.start()
mode_hint = ""
if action in ("crawl_sentiment", "crawl_sentiment_export"):
mode_hint = (f" 🆕 全网舆情模式: targets={st_list or '默认'} 类别={sc_list or '默认'} "
f"媒类别={ssc_list or '全部'} days={sentiment_days}\n")
return (
f"✅ 定时任务已创建\n"
f" ID: {task.task_id}\n"
f" 名称: {task.name}\n"
f" 频率: {task.frequency.value}\n"
f" 动作: {task.action.value}\n"
f" 下次执行: {task.next_run_at}\n"
f"{mode_hint}"
f" \n💡 使用 list_scheduled_tasks 查看所有任务"
)
except Exception as e:
return f"创建任务失败: {str(e)}"
@server.tool()
async def list_scheduled_tasks(status: str = "") -> str:
"""查看所有定期爬取任务及执行状态。
Args:
status: 按状态筛选 — active / paused / completed / error(留空查看全部)
"""
try:
if get_scheduler is None:
return _module_error("crawl_scheduler")
scheduler = get_scheduler()
tasks = scheduler.list_tasks(status=status if status else None)
if not tasks:
return "📋 暂无定时任务。使用 schedule_crawl_task 创建新任务。"
lines = [f"📋 定时任务列表 ({len(tasks)} 个):", ""]
for t in tasks:
freq_map = {
"every_minute": "每分钟", "every_5_minutes": "每5分钟",
"every_10_minutes": "每10分钟", "every_30_minutes": "每30分钟",
"hourly": "每小时", "every_6_hours": "每6小时",
"every_12_hours": "每12小时", "daily": "每天",
"weekly": "每周", "monthly": "每月",
}
freq_label = freq_map.get(t.frequency.value, t.frequency.value)
status_icon = {"active": "🟢", "paused": "🟡", "completed": "✅", "error": "🔴"}.get(t.status.value, "⚪")
lines.append(f" {status_icon} [{t.task_id}] {t.name}")
lines.append(f" 频率: {freq_label} | 动作: {t.action.value} | 已执行: {t.run_count}次")
if t.last_run_at:
lines.append(f" 上次: {t.last_run_at}")
if t.next_run_at:
lines.append(f" 下次: {t.next_run_at}")
if t.last_error:
lines.append(f" 错误: {t.last_error[:100]}")
lines.append("")
lines.append(f"调度器状态: {'▶️ 运行中' if scheduler.is_running() else '⏸️ 已停止'}")
return '\n'.join(lines)
except Exception as e:
return f"查询失败: {str(e)}"
@server.tool()
async def cancel_scheduled_task(task_id: str, action: str = "delete") -> str:
"""取消或暂停定期爬取任务。
Args:
task_id: 任务ID(从 list_scheduled_tasks 获取)
action: 操作 — delete(删除) / pause(暂停) / resume(恢复)
"""
try:
if get_scheduler is None:
return _module_error("crawl_scheduler")
scheduler = get_scheduler()
if action == "pause":
ok = scheduler.pause_task(task_id)
return f"⏸️ 任务 {task_id} 已暂停" if ok else f"❌ 未找到任务 {task_id}"
elif action == "resume":
ok = scheduler.resume_task(task_id)
return f"▶️ 任务 {task_id} 已恢复" if ok else f"❌ 未找到任务 {task_id}"
else:
ok = scheduler.delete_task(task_id)
return f"🗑️ 任务 {task_id} 已删除" if ok else f"❌ 未找到任务 {task_id}"
except Exception as e:
return f"操作失败: {str(e)}"
# ==================== v4.0 批量爬取打包 ZIP ====================
@server.tool()
async def batch_crawl_and_package(names: str = "",
institution_type: str = "",
zip_name: str = "") -> str:
"""批量爬取机构信息并自动打包为 ZIP 文件。
Args:
names: 机构名称列表,逗号分隔,如"易方达基金,华夏基金"
institution_type: 机构类型,如"基金管理公司"(与names二选一)
zip_name: 自定义ZIP文件名(可选)
"""
try:
if _batch_crawl_and_package is None:
return _module_error("crawl_packager")
if not names and not institution_type:
return "请提供 names 或 institution_type 参数。"
path = _batch_crawl_and_package(
names=names, institution_type=institution_type, zip_name=zip_name
)
size_mb = os.path.getsize(path) / (1024 * 1024) if os.path.exists(path) else 0
return f"📦 ZIP 打包完成\n 文件: {path}\n 大小: {size_mb:.2f} MB"
except Exception as e:
return f"打包失败: {str(e)}"
# ==================== v4.0 内容压缩 ====================
@server.tool()
async def compress_crawl_results(source: str = "",
file_path: str = "",
focus: str = "全面",
max_pages: int = 3) -> str:
"""分析爬取内容或文件,提取关键信息并压缩为 2-3 页结构化摘要。
适用场景:一大段爬取内容/一份PDF/一份研报 → 2-3页精华摘要。
Args:
source: 文本内容(直接输入)
file_path: 文件路径(与source二选一)
focus: 关注维度 — 财务 / 风险 / 行业 / 政策 / 事件 / 全面
max_pages: 目标页数(默认3)
"""
try:
if compress_content is None:
return _module_error("content_compressor")
target = file_path if file_path else source
if not target:
return "请提供 source(文本)或 file_path(文件路径)。"
config = CompressConfig(focus=focus, max_pages=max_pages,
max_chars=max_pages * 1000)
result = compress_content(target, focus=focus)
lines = [
f"📄 内容压缩报告",
f"标题: {result.title}",
f"摘要: {result.summary[:200]}",
f"",
f"--- 压缩报告 ---",
result.structured_report,
f"",
f"📊 压缩统计: 原文 {result.stats['source_chars']:,} 字 → "
f"压缩后 {result.stats['output_chars']:,} 字 "
f"({result.stats['compression_ratio']})",
]
return '\n'.join(lines)
except Exception as e:
return f"压缩失败: {str(e)}"
# ==================== v4.0 增强文件解析 ====================
@server.tool()
async def parse_file_enhanced(file_path: str, fmt: str = "auto") -> str:
"""增强文件解析器。支持 PPT/PPTX、HTML网页、Markdown、CSV、JSON 等格式。
自动检测文件类型,提取文本、表格、元数据等。
适用场景:解析 PPT 演示文稿、网页内容、Markdown 文档等。
Args:
file_path: 文件路径或URL
fmt: 格式 — auto(自动检测) / pptx / html / markdown / csv / json
"""
try:
if _parse_file_enhanced is None:
return _module_error("enhanced_parser")
result = _parse_file_enhanced(file_path, fmt)
if result.get("error"):
return f"❌ {result['error']}"
lines = [f"✅ 解析成功: {result.get('file_type', '?').upper()}"]
if result.get("title"):
lines.append(f"标题: {result['title']}")
if result.get("slide_count"):
lines.append(f"幻灯片: {result['slide_count']} 页")
if result.get("tables"):
lines.append(f"表格: {len(result['tables'])} 个")
if result.get("sections"):
lines.append(f"章节: {len(result.get('sections', result.get('headings', [])))} 个")
if result.get("char_count"):
lines.append(f"字符: {result['char_count']:,}")
text = result.get("text_content", "")[:2000]
lines.append(f"\n--- 前2000字符 ---\n{text}")
return '\n'.join(lines)
except Exception as e:
return f"解析失败: {str(e)}"
# ==================== v4.0 深度文件分析 ====================
@server.tool()
async def analyze_file_deep(file_path: str, analysis_type: str = "full") -> str:
"""深度分析文件内容。提取主题、情感倾向、趋势信号等。
Args:
file_path: 文件路径
analysis_type: 分析类型 — full(全面) / topics(主题) / financial(财务) / risk(风险)
"""
try:
# 先解析
if parse_file_enhanced is None:
return _module_error("enhanced_parser")
parsed = _parse_file_enhanced(file_path)
if parsed.get("error"):
return f"❌ 解析失败: {parsed['error']}"
# 再压缩分析
if compress_content is None:
return _module_error("content_compressor")
text = parsed.get("text_content", "")[:10000]
if not text:
return "❌ 文件内容为空"
focus_map = {"full": "全面", "topics": "行业", "financial": "财务", "risk": "风险"}
focus = focus_map.get(analysis_type, "全面")
result = compress_content(text, focus=focus)
lines = [f"🔍 深度文件分析: {Path(file_path).name}", ""]
lines.append(f"## 文档分类")
try:
from document_analyzer import classify_document
cat, conf = classify_document(text, file_path)
lines.append(f" {cat} (置信度: {conf}%)")
except ImportError:
pass
lines.append(f"\n## 关键发现\n")
for i, pt in enumerate(result.key_points, 1):
lines.append(f" {i}. {pt}")
lines.append(f"\n## 财务指标 ({len(result.financial_highlights)} 项)")
for k, v in list(result.financial_highlights.items())[:8]:
lines.append(f" {k}: {v}")
if result.risk_summary:
lines.append(f"\n## 风险因素 ({len(result.risk_summary)} 条)")
for r in result.risk_summary[:5]:
lines.append(f" ⚠️ {r}")
lines.append(f"\n📊 原文 {result.stats['source_chars']:,} 字 | "
f"压缩比 {result.stats['compression_ratio']}")
return '\n'.join(lines)
except Exception as e:
return f"分析失败: {str(e)}"
# ==================== v4.0 研究报告生成 ====================
@server.tool()
async def generate_research_report(source: str = "",
theme: str = "stock_research",
title: str = "",
focus: str = "全面",
file_path: str = "",
stock_code: str = "",
output_formats: str = "markdown") -> str:
"""生成图文并茂的金融研究报告。支持从URL/文件/股票代码一键生成。
自动采集数据 → 分析压缩 → 报告写作 → 图表生成 → 多格式导出。
适用场景:生成个股研报、行业分析、基金评价、市场周报等。
Args:
source: 文本内容或URL(可选)
theme: 报告主题 — stock_research(个股研报) / industry_analysis(行业分析) / fund_evaluation(基金评价) / institution_survey(机构调研) / market_weekly(市场周报) / announcement_brief(公告解读)
title: 报告标题(可选,自动生成)
focus: 关注维度 — 财务/风险/行业/政策/事件/全面
file_path: 文件路径(可选,作为数据源)
stock_code: 6位股票代码(可选,自动获取报告数据)
output_formats: 输出格式,逗号分隔 — markdown/docx/pptx/html/pdf
"""
try:
if ResearchReportGenerator is None:
return _module_error("research_report_generator")
# 构建配置
from research_report_generator import ReportConfig, ReportTheme, OutputFormat
config = ReportConfig(
theme=ReportTheme(theme),
title=title,
focus_dimension=focus,
)
if source and source.startswith(('http://', 'https://')):
config.urls = [source]
elif file_path and os.path.isfile(file_path):
config.files = [file_path]
elif stock_code:
config.stock_codes = [stock_code]
elif source:
config.custom_data = {"raw_text": source}
# 设置输出格式
fmt_map = {"markdown": OutputFormat.MARKDOWN, "docx": OutputFormat.DOCX,
"pptx": OutputFormat.PPTX, "html": OutputFormat.HTML,
"pdf": OutputFormat.PDF}
config.output_formats = [
fmt_map[f.strip()] for f in output_formats.split(",")
if f.strip() in fmt_map and f.strip() != "markdown"
]
gen = ResearchReportGenerator()
result = gen.generate(config)
lines = [result.to_summary()]
lines.append(f"\n--- 报告预览 (前500字) ---")
lines.append(result.markdown_content[:500])
if len(result.markdown_content) > 500:
lines.append(f"\n... 全文共 {len(result.markdown_content):,} 字")
lines.append(f"\n📊 图表: {len(result.chart_paths)} 张")
return '\n'.join(lines)
except Exception as e:
return f"报告生成失败: {str(e)}"
@server.tool()
async def export_research_report(markdown_content: str = "",
file_path: str = "",
output_formats: str = "docx",
title: str = "研究报告") -> str:
"""将 Markdown 报告内容导出为 Word/PPT/HTML/PDF 格式。
Args:
markdown_content: Markdown 文本内容(与file_path二选一)
file_path: Markdown 文件路径(与markdown_content二选一)
output_formats: 导出格式,逗号分隔 — docx/pptx/html/pdf
title: 报告标题
"""
try:
if file_path and os.path.isfile(file_path):
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
elif markdown_content:
content = markdown_content
else:
return "请提供 markdown_content 或 file_path。"
from research_report_generator import ReportResult, ReportConfig, OutputFormat
from datetime import datetime
# 构造 ReportResult
result = ReportResult(
config=ReportConfig(),
title=title,
template_id="custom",
markdown_content=content,
created_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
# 导出
gen = ResearchReportGenerator()
fmt_map = {"markdown": OutputFormat.MARKDOWN, "docx": OutputFormat.DOCX,
"pptx": OutputFormat.PPTX, "html": OutputFormat.HTML,
"pdf": OutputFormat.PDF}
config = ReportConfig()
config.output_formats = [
fmt_map[f.strip()] for f in output_formats.split(",")
if f.strip() in fmt_map
]
output_files = gen._export(result, config)
lines = [f"📦 报告导出结果", ""]
for fmt, path in output_files.items():
size = os.path.getsize(path) if os.path.exists(path) else 0
lines.append(f" ✅ [{fmt.upper()}] {path} ({size/1024:.1f} KB)")
return '\n'.join(lines)
except Exception as e:
return f"导出失败: {str(e)}"
# ==================== v4.0 快速摘要 ====================
@server.tool()
async def quick_crawl_summary(url_or_file: str,
focus: str = "全面") -> str:
"""一键快速爬取+压缩。输入 URL 或文件路径,自动爬取/解析并输出 2-3 页精华摘要。
Args:
url_or_file: URL 或文件路径
focus: 关注维度 — 财务/风险/行业/政策/事件/全面
"""
try:
if compress_content is None:
return _module_error("content_compressor")
# 判断是URL还是文件
if url_or_file.startswith(('http://', 'https://')):
# 爬取
if FinancialPageScraper is None:
return _module_error("scraper")
scraper = FinancialPageScraper()
content = scraper.scrape_url(url_or_file)
if not content:
return f"❌ 爬取失败: {url_or_file}"
text = str(content)[:15000]
elif os.path.isfile(url_or_file):
# 解析
if parse_file_enhanced:
parsed = parse_file_enhanced(url_or_file)
text = parsed.get("text_content", "")[:15000]
else:
with open(url_or_file, 'r', encoding='utf-8') as f:
text = f.read()[:15000]
else:
return f"❌ 无法识别来源: {url_or_file}"
if not text:
return "❌ 内容为空"
result = compress_content(text, focus=focus)
lines = [
f"⚡ 快速摘要",
f"来源: {url_or_file}",
f"标题: {result.title}",
f"",
result.structured_report,
f"",
f"📊 {result.stats['source_chars']:,} 字 → "
f"{result.stats['output_chars']:,} 字 ({result.stats['compression_ratio']})",
]
return '\n'.join(lines)
except Exception as e:
return f"快速摘要失败: {str(e)}"
# ==================== 主入口 ====================
# ==================== v4.3 全网舆情爬虫 ====================
@server.tool()
async def crawl_global_sentiment(
targets: str = "",
target_categories: str = "",
sources: str = "",
source_categories: str = "",
days: int = 7,
positive_only: bool = False,
negative_only: bool = False,
max_articles: int = 80,
fmt: str = "dialog",
run_backtest: bool = True,
dry_run: bool = False,
strict_date: bool = True) -> str:
"""🆕 v4.6 全网舆情爬虫(正面新闻+舆情)+ 强制日期过滤 + 数据回测。
根据用户对话要求,从权威媒体/财经垂直/地方媒体/自媒体/国际媒体
采集基金公司/上市公司/证券公司/银行/保险/信托等机构的
正面新闻与舆情。
🛡️ v4.6 增强:
- 严格日期过滤:仅返回请求时间窗内的文章(默认启用)
- 默认开启数据回测(4维验证:新鲜度/交叉源/历史快照/数字一致性)
- 爬取后日期核验统计(date_validation 字段)
- 不延展爬取范围:严格按用户指定的目标/媒体/时间窗执行
支持:
- 指定单个或多个机构(targets,逗号分隔),仅看正面或仅看舆情
- 限定目标类别(fund_company / listed_company / securities 等)
- 限定媒体类别(authoritative / financial_vertical / self_media / international)
- 时间窗口(默认 7 天,严格过滤)
- 多种输出方式(dialog/word/excel/csv/json/all/auto)
- 数据回测(默认开启,对每条结果做 4 维验证)
- 预览模式(dry_run=True 时仅展示爬取计划,不发 HTTP 请求)
Args:
targets: 目标名列表,逗号分隔(如"贵州茅台,工银瑞信基金,招商银行")
target_categories: 目标类别,逗号分隔(fund_company/listed_company/securities/commercial_bank/insurance/trust_company/private_fund/foreign_institution/futures/wealth_management/leasing_consumer_finance,留空=选 top 类)
sources: 媒体名称列表(精确匹配,可选)
source_categories: 媒体类别,逗号分隔(authoritative/financial_vertical/self_media/international)
days: 时间窗口,默认7(严格过滤,不返回时间窗外文章)
positive_only: 仅保留正面新闻
negative_only: 仅保留舆情
max_articles: 最大文章数
fmt: 输出格式 dialog/word/excel/csv/json/all/auto
run_backtest: 是否对每条结果做 4 维数据回测(默认开启)
dry_run: 是否仅预览爬取计划(不发 HTTP 请求,用于爬取前确认)
strict_date: 是否严格日期过滤(默认True,丢弃时间窗外文章)
"""
try:
if _crawl_sentiment is None:
return _module_error("sentiment_crawler")
# 解析参数
target_list = [t.strip() for t in targets.split(",") if t.strip()] or None
cat_list = [c.strip() for c in target_categories.split(",") if c.strip()] or None
src_list = [s.strip() for s in sources.split(",") if s.strip()] or None
sc_list = [c.strip() for c in source_categories.split(",") if c.strip()] or None
snapshot = _crawl_sentiment(
targets=target_list,
categories=cat_list,
sources=src_list,
source_categories=sc_list,
days=days,
positive_only=positive_only,
negative_only=negative_only,
max_articles=max_articles,
dry_run=dry_run,
confirmed=not dry_run,
run_backtest=run_backtest,
)
if not snapshot or not snapshot.articles:
return (
"📭 本次未获取到结果,建议:\n"
" · 放宽时间窗 days=30\n"
" · 添加更多 media source / target\n"
" · 重试或检查网络"
)
# 输出
if fmt == "dialog":
from sentiment_exporter import to_dialog
return to_dialog(snapshot)
if _export_sentiment is None:
return _module_error("sentiment_exporter")
outputs = _export_sentiment(snapshot, fmt=fmt)
lines = [
f"✅ 全网舆情快照 {snapshot.snapshot_id}",
f" 共 {snapshot.stats.get('total', 0)} 条 | 正面 {snapshot.positive_count()} | "
f"舆情 {snapshot.negative_count()} | 中性 {snapshot.neutral_count()}",
"",
]
for k, v in outputs.items():
if k == "dialog":
lines.append(f"--- 对话提示 ---\n{v}")
else:
lines.append(f"📦 [{k.upper()}] {v}")
lines.append(f"\n💡 提示:可继续运行 export_sentiment_report 重新导出,或查看 data/sentiment_snapshots/")
return '\n'.join(lines)
except Exception as e:
return f"❌ 舆情爬取失败: {str(e)}"
@server.tool()
async def export_sentiment_report(snapshot_id: str = "",
file_path: str = "",
fmt: str = "all") -> str:
"""🆕 v4.3 导出已有舆情快照。可直接传入快照ID或快照JSON文件路径。
Args:
snapshot_id: 快照ID(snapshot_id 形如 sn_xxxx),自动从 data/sentiment_snapshots/ 找到
file_path: 快照JSON文件路径(与snapshot_id二选一)
fmt: 输出格式 dialog/word/excel/csv/json/all/auto
"""
try:
if _export_sentiment is None:
return _module_error("sentiment_exporter")
snap_file: Optional[Path] = None
if file_path and Path(file_path).is_file():
snap_file = Path(file_path)
elif snapshot_id:
base = SCRIPT_DIR / "data" / "sentiment_snapshots"
candidate = base / f"{snapshot_id}.json"
if candidate.exists():
snap_file = candidate
else:
index_file = base / "index.json"
if index_file.exists():
for item in json.loads(index_file.read_text(encoding="utf-8")):
if item.get("snapshot_id") == snapshot_id:
snap_file = Path(item["path"])
break
if not snap_file:
return f"❌ 未找到快照: {snapshot_id or file_path}"
# 加载快照
from sentiment_crawler import SentimentSnapshot, SentimentArticle # type: ignore
raw = json.loads(snap_file.read_text(encoding="utf-8"))
arts = [SentimentArticle(**a) for a in raw.get("articles", [])]
snap = SentimentSnapshot(
snapshot_id=raw["snapshot_id"], created_at=raw["created_at"],
target_filter=raw.get("target_filter", {}),
source_filter=raw.get("source_filter", []),
articles=arts,
stats=raw.get("stats", {}),
)
snap.extra_path = str(snap_file) # type: ignore[attr-defined]
outputs = _export_sentiment(snap, fmt=fmt)
lines = []
for k, v in outputs.items():
if k == "dialog":
lines.append(v)
else:
lines.append(f"📦 [{k.upper()}] {v}")
return '\n'.join(lines) if lines else "无输出"
except Exception as e:
return f"❌ 导出失败: {str(e)}"
@server.tool()
async def list_sentiment_targets() -> str:
"""🆕 v4.3 查看全网舆情爬虫的目标库。
返回的分类:fund_company / listed_company / securities /
commercial_bank / insurance / trust_company / private_fund /
foreign_institution / futures / wealth_management / leasing_consumer_finance
"""
try:
if _list_sentiment_targets is None:
return _module_error("sentiment_crawler")
items = _list_sentiment_targets()
lines = ["🎯 全网舆情目标库", ""]
for it in items:
lines.append(f" · {it.get('category','')} — {it.get('label','')} ({it.get('count',0)} 个)")
lines.append("")
lines.append("💡 用法:\n · crawl_global_sentiment(targets='工银瑞信基金,贵州茅台')\n"
" · crawl_global_sentiment(target_categories='fund_company,listed_company')")
return '\n'.join(lines)
except Exception as e:
return f"查询失败: {str(e)}"
@server.tool()
async def list_sentiment_sources(category: str = "") -> str:
"""🆕 v4.3 查看全网舆情爬虫的媒体源库。
Args:
category: 媒体类别(authoritative/financial_vertical/self_media/international),留空=全部
"""
try:
if SentimentSourceLoader is None:
return _module_error("sentiment_crawler")
loader = SentimentSourceLoader()
if category:
items = loader.sources_by_category(category)
items = [(category, x) for x in items]
else:
items = [(loader._data[k][0].get('category', k) if loader._data.get(k) else k, x)
for k in loader.list_categories()
for x in loader.sources_by_category(k)]
lines = [f"📰 全网舆情媒体源 (类别: {category or '全部'})", ""]
for cat, item in items:
tags = ",".join(item.get("tags", []) or [])
lines.append(f" · [{cat}] {item.get('name','')} — {tags}")
return '\n'.join(lines)
except Exception as e:
return f"查询失败: {str(e)}"
@server.tool()
async def add_sentiment_target(category: str, name: str, aliases: str = "") -> str:
"""🆕 v4.3 新增自定义舆情目标。
Args:
category: 目标类别(fund_company / listed_company / securities / commercial_bank / insurance / trust_company / private_fund / foreign_institution / futures / wealth_management / leasing_consumer_finance / custom)
name: 目标名称
aliases: 别名列表(逗号分隔,可选)
"""
try:
if _add_custom_target is None:
return _module_error("sentiment_crawler")
alias_list = [a.strip() for a in aliases.split(",") if a.strip()] or None
result = _add_custom_target(category, name, alias_list)
if result.get("ok"):
return f"✅ 已新增 {category} - {name}(含 {len(alias_list or [])} 个别名)"
return f"⚠️ {result.get('msg', '未知错误')}"
except Exception as e:
return f"❌ 新增失败: {str(e)}"
# ==================== v4.5 全页归档 + 搜索工具 ====================
try:
from fullpage_archiver import FullPageArchiver as _FullPageArchiver, \
quick_archive as _quick_archive
_HAS_ARCHIVER = True
except ImportError:
_HAS_ARCHIVER = False
try:
from search_engine import MultiEngineSearch as _MultiEngineSearch, \
search_and_fetch as _search_and_fetch
_HAS_SEARCH = True
except ImportError:
_HAS_SEARCH = False
# v4.7 新增数据源(东方财富数据中心 / 市场宏观 / 北交所 / 浏览器 / 监管政策 / 对比回测)
try:
from eastmoney_scraper import (
get_margin_trading as _get_margin_trading,
get_block_trades as _get_block_trades,
get_etf_fund_flow as _get_etf_fund_flow,
get_dragon_tiger_seats as _get_dragon_tiger_seats,
get_shareholder_changes as _get_shareholder_changes,
)
except ImportError:
_get_margin_trading = _get_block_trades = _get_etf_fund_flow = \
_get_dragon_tiger_seats = _get_shareholder_changes = None
try:
from market_data_scraper import get_lpr_rates as _get_lpr_rates, \
get_bond_yield_curve as _get_bond_yield_curve
except ImportError:
_get_lpr_rates = _get_bond_yield_curve = None
try:
from exchange_scraper import get_bse_stocks as _get_bse_stocks
except ImportError:
_get_bse_stocks = None
try:
from data_comparator import compare_and_backtest as _compare_and_backtest, \
format_comparison_report as _format_comparison_report
except ImportError:
_compare_and_backtest = _format_comparison_report = None
try:
from regulatory_scraper import analyze_policy_document as _analyze_policy_document, \
search_policy_documents as _search_policy_documents, \
crawl_policy_documents as _crawl_policy_documents
except ImportError:
_analyze_policy_document = _search_policy_documents = _crawl_policy_documents = None
@server.tool()
async def archive_webpage(url: str, paginate: bool = False,
next_selector: str = "",
max_pages: int = 5,
output_mode: str = "both") -> str:
"""v4.5 全页归档:下载网页全部内容(文字+图片+图表+表格),生成自包含 HTML。
支持翻页抓取、图片 base64 内嵌、Canvas 截图、表格提取。
输出模式: "inline"(单文件内嵌) / "directory"(独立目录) / "both"(两者都要,默认)
Args:
url: 目标网页 URL
paginate: 是否自动翻页
next_selector: 下一页按钮 CSS 选择器(翻页时必填,如 ".next,a[rel=next]")
max_pages: 最大翻页数(默认 5)
output_mode: 输出模式(默认 both)
"""
if not _HAS_ARCHIVER:
return _module_error("fullpage_archiver")
try:
result = _quick_archive(
url, paginate=paginate,
next_selector=next_selector or None,
max_pages=max_pages,
inline_images=output_mode in ("inline", "both"),
save_assets=output_mode in ("directory", "both"),
)
return result.summary
except Exception as e:
return f"❌ 归档失败: {str(e)}"
@server.tool()
async def search_web(query: str, engines: str = "",
limit: int = 10) -> str:
"""v4.5 多引擎搜索:跨 DuckDuckGo/Bing/SearXNG 搜索,返回结构化结果。
零 API Key 即可使用(优先 DuckDuckGo + SearXNG,Bing HTML 兜底)。
Args:
query: 搜索关键词(如 "贵州茅台 2026年报")
engines: 指定引擎(逗号分隔,如 "duckduckgo,bing_html"),空=自动选择
limit: 返回条数(默认 10)
"""
if not _HAS_SEARCH:
return _module_error("search_engine")
try:
engine_list = [e.strip() for e in engines.split(",") if e.strip()] or None
s = _MultiEngineSearch(engines=engine_list)
results = s.search(query, limit=limit)
if not results:
return "🔍 未找到相关结果。可尝试换关键词或稍后重试。"
lines = [f"🔍 搜索: {query} | 共 {len(results)} 条"]
for i, r in enumerate(results, 1):
lines.append(
f"{i}. [{r.source_engine}] {r.title[:80]}\n"
f" {r.url[:100]}\n"
f" {r.snippet[:120]}"
)
return "\n\n".join(lines)
except Exception as e:
return f"❌ 搜索失败: {str(e)}"
@server.tool()
async def search_and_archive(query: str, engines: str = "",
limit: int = 5,
fetch_content: bool = True) -> str:
"""v4.5 搜索+归档一步完成:搜索 → 获取 URL → 爬取详情 → 返回结构化内容。
Args:
query: 搜索关键词
engines: 指定引擎(逗号分隔),空=自动选择
limit: 结果条数(默认 5)
fetch_content: 是否自动爬取详情页文本(默认 True)
"""
if not _HAS_SEARCH:
return _module_error("search_engine")
try:
results = _search_and_fetch(query, limit=limit, fetch_content=fetch_content)
if not results:
return "🔍 未找到相关结果。"
lines = [f"🔍 搜索+归档: {query} | 共 {len(results)} 条"]
for i, r in enumerate(results, 1):
content_preview = (r.get("content", "") or "")[:200]
lines.append(
f"{i}. {r['title'][:80]}\n"
f" 来源: {r.get('source_engine', '?')} | "
f"可信度: {r.get('credibility', '?')}/10\n"
f" URL: {r['url'][:100]}\n"
f" 摘要: {r.get('snippet', '')[:120]}\n"
f" 正文: {content_preview}..."
)
return "\n\n".join(lines)
except Exception as e:
return f"❌ 搜索归档失败: {str(e)}"
# ==================== v4.7 新增工具 ====================
def _format_data_rows(rows, limit: int = 20) -> str:
"""将数据行列表格式化为可读文本(默认最多 20 行)。"""
if not rows:
return "(无数据)"
if isinstance(rows, dict):
rows = [rows]
lines = [f"共 {len(rows)} 条:"]
for row in rows[:limit]:
if isinstance(row, dict):
parts = []
for k, v in row.items():
if v is None or v == "" or v == 0.0:
continue
parts.append(f"{k}={v}")
lines.append(" " + " | ".join(parts))
else:
lines.append(f" {row}")
if len(rows) > limit:
lines.append(f" … 等 {len(rows)} 条(仅显示前 {limit})")
return "\n".join(lines)
def _dry_run_preview(data_type: str, params: dict) -> str:
"""数据爬取前的确认预览(不实际执行请求)。"""
lines = [
f"📋 爬取计划确认({data_type})",
"=" * 40,
"以下参数将用于本次爬取:",
]
for k, v in params.items():
if v not in (None, "", 0, False):
lines.append(f" • {k}: {v}")
lines.append("")
lines.append("⚠️ 如需执行请去掉 dry_run=True 后再次调用。")
return "\n".join(lines)
@server.tool()
async def get_margin_trading(market: str = "all", limit: int = 50,
start_date: str = "", end_date: str = "",
dry_run: bool = False) -> str:
"""v4.7 融资融券(两融)余额数据:融资余额/融券余额/融资净买入等。
Args:
market: all / sz / sh / bj
limit: 返回条数(默认 50)
start_date: 起始日期 yyyy-MM-dd(严格过滤)
end_date: 截止日期 yyyy-MM-dd
dry_run: True=仅预览爬取计划不执行(默认 False)
"""
if dry_run:
return _dry_run_preview("两融数据", {"market": market, "limit": limit,
"start_date": start_date, "end_date": end_date})
if _get_margin_trading is None:
return _module_error("eastmoney_scraper")
try:
rows = _get_margin_trading(market=market, limit=limit,
start_date=start_date, end_date=end_date)
return _format_data_rows(rows)
except Exception as e:
return f"❌ 获取两融数据失败: {str(e)}"
@server.tool()
async def get_block_trades(stock_code: str = "", start_date: str = "",
end_date: str = "", limit: int = 50,
dry_run: bool = False) -> str:
"""v4.7 大宗交易数据:成交价/折溢价率/买卖营业部。
Args:
stock_code: 股票代码(可选)
start_date: 起始日期 yyyy-MM-dd
end_date: 截止日期 yyyy-MM-dd
limit: 返回条数(默认 50)
dry_run: True=仅预览不执行
"""
if dry_run:
return _dry_run_preview("大宗交易", {"stock_code": stock_code, "limit": limit,
"start_date": start_date, "end_date": end_date})
if _get_block_trades is None:
return _module_error("eastmoney_scraper")
try:
rows = _get_block_trades(stock_code=stock_code, start_date=start_date,
end_date=end_date, limit=limit)
return _format_data_rows(rows)
except Exception as e:
return f"❌ 获取大宗交易失败: {str(e)}"
@server.tool()
async def get_etf_fund_flow(sort_by: str = "net_inflow", limit: int = 50,
dry_run: bool = False) -> str:
"""v4.7 ETF 资金流向:主力/超大单/大单净流入排行。
Args:
sort_by: net_inflow(主力净流入) / change_pct(涨跌幅)
limit: 返回条数(默认 50)
dry_run: True=仅预览不执行
"""
if dry_run:
return _dry_run_preview("ETF资金流向", {"sort_by": sort_by, "limit": limit})
if _get_etf_fund_flow is None:
return _module_error("eastmoney_scraper")
try:
rows = _get_etf_fund_flow(sort_by=sort_by, limit=limit)
return _format_data_rows(rows)
except Exception as e:
return f"❌ 获取 ETF 资金流失败: {str(e)}"
@server.tool()
async def get_dragon_tiger_seats(stock_code: str, date: str = "") -> str:
"""v4.7 龙虎榜席位详情:买卖营业部、总买/总卖/净买入。
Args:
stock_code: 股票代码(必填)
date: 日期 yyyy-MM-dd(可选,默认最近)
"""
if _get_dragon_tiger_seats is None:
return _module_error("eastmoney_scraper")
try:
r = _get_dragon_tiger_seats(stock_code, date)
return _format_data_rows(r)
except Exception as e:
return f"❌ 获取龙虎榜席位失败: {str(e)}"
@server.tool()
async def get_shareholder_changes(stock_code: str = "", change_type: str = "all",
start_date: str = "", end_date: str = "",
limit: int = 50, dry_run: bool = False) -> str:
"""v4.7 股东/高管增减持数据。
Args:
stock_code: 股票代码(可选)
change_type: all / 增持 / 减持
start_date: 起始日期 yyyy-MM-dd
end_date: 截止日期 yyyy-MM-dd
limit: 返回条数(默认 50)
dry_run: True=仅预览不执行
"""
if dry_run:
return _dry_run_preview("股东增减持", {"stock_code": stock_code,
"change_type": change_type, "limit": limit})
if _get_shareholder_changes is None:
return _module_error("eastmoney_scraper")
try:
rows = _get_shareholder_changes(stock_code=stock_code, change_type=change_type,
start_date=start_date, end_date=end_date, limit=limit)
return _format_data_rows(rows)
except Exception as e:
return f"❌ 获取股东增减持失败: {str(e)}"
@server.tool()
async def get_lpr_rates(days: int = 365, dry_run: bool = False) -> str:
"""v4.7 LPR 贷款市场报价利率(1年期/5年期)。
Args:
days: 返回最近 N 天内的报价(默认 365)
dry_run: True=仅预览不执行
"""
if dry_run:
return _dry_run_preview("LPR利率", {"days": days})
if _get_lpr_rates is None:
return _module_error("market_data_scraper")
try:
rows = _get_lpr_rates(days=days)
lines = ["📊 LPR 贷款市场报价利率:"]
for r in rows[:12]:
lines.append(f" {r['date']}: 1年期 {r['one_year']}% | 5年期 {r['five_year']}%")
if len(rows) > 12:
lines.append(f" … 共 {len(rows)} 条")
return "\n".join(lines) if rows else "(无数据)"
except Exception as e:
return f"❌ 获取 LPR 失败: {str(e)}"
@server.tool()
async def get_bond_yield_curve(dry_run: bool = False) -> str:
"""v4.7 国债收益率曲线(1Y-30Y 各期限收益率 + 利差)。
Args:
dry_run: True=仅预览不执行
"""
if dry_run:
return _dry_run_preview("国债收益率曲线", {"curve_type": "treasury"})
if _get_bond_yield_curve is None:
return _module_error("market_data_scraper")
try:
r = _get_bond_yield_curve()
if "error" in r:
return f"❌ {r.get('error')}({r.get('warning', '')})"
terms = r.get("terms", {})
lines = [f"📈 {r.get('curve_type')}({r.get('date')})"]
for k in ("1Y", "2Y", "3Y", "5Y", "7Y", "10Y", "30Y"):
if k in terms:
lines.append(f" {k}: {terms[k]}%")
if "spread_10_2" in r:
lines.append(f" 10Y-2Y 利差: {r['spread_10_2']}%")
if "spread_30_10" in r:
lines.append(f" 30Y-10Y 利差: {r['spread_30_10']}%")
return "\n".join(lines)
except Exception as e:
return f"❌ 获取国债收益率失败: {str(e)}"
@server.tool()
async def get_bse_stocks(limit: int = 200, dry_run: bool = False) -> str:
"""v4.7 北交所上市公司列表。
Args:
limit: 返回条数(默认 200)
dry_run: True=仅预览不执行
"""
if dry_run:
return _dry_run_preview("北交所上市公司列表", {"limit": limit})
if _get_bse_stocks is None:
return _module_error("exchange_scraper")
try:
rows = _get_bse_stocks(limit=limit)
lines = [f"🏛 北交所上市公司({len(rows)} 家):"]
for r in rows[:20]:
lines.append(f" {r.get('code')} {r.get('name')} "
f"上市:{r.get('listing_date', '')} 行业:{r.get('industry', '')}")
if len(rows) > 20:
lines.append(f" … 共 {len(rows)} 家")
return "\n".join(lines)
except Exception as e:
return f"❌ 获取北交所列表失败: {str(e)}"
@server.tool()
async def browser_human_fetch(url: str, scroll: bool = True,
mouse_simulation: bool = True,
dwell: bool = True,
random_viewport: bool = True,
input_text: str = "",
input_selector: str = "",
think_time: bool = True,
hover: bool = True,
tab_switch: bool = True,
include_iframes: bool = False) -> str:
"""v6.0.0 类人浏览器抓取:模拟真实用户(鼠标移动+阅读停顿+悬停+多标签+随机滚动)获取高反爬网页。
Args:
url: 目标网页 URL
scroll: 是否随机滚动(默认 True)
mouse_simulation: 是否模拟贝塞尔鼠标移动(默认 True)
dwell: 是否自适应停留阅读(默认 True)
random_viewport: 是否随机 viewport(默认 True)
input_text: 需要模拟输入的文本(如搜索关键词),空则不输入
input_selector: 输入框 CSS 选择器(input_text 非空时建议提供)
think_time: v6.0.0 阅读停顿(加载后停留 2-8s,默认 True)
hover: v6.0.0 随机悬停菜单触发下拉内容(默认 True)
tab_switch: v6.0.0 低频开新标签页再切回(默认 True)
include_iframes: v6.0.0 合并内嵌 iframe 内容(默认 False)
"""
try:
from browser_scraper import BrowserScraper
PLAYWRIGHT_AVAILABLE = True
except ImportError:
try:
from scripts.browser_scraper import BrowserScraper
PLAYWRIGHT_AVAILABLE = True
except ImportError:
PLAYWRIGHT_AVAILABLE = False
if not PLAYWRIGHT_AVAILABLE:
return ("❌ 浏览器模块不可用。请安装:\n"
" pip install playwright\n"
" playwright install chromium")
try:
with BrowserScraper(headless=True, stealth=True) as bs:
html = bs.humanlike_fetch(
url, scroll=scroll, dwell=dwell,
random_viewport=random_viewport,
mouse_simulation=mouse_simulation,
input_selector=input_selector or None,
input_text=input_text or None,
think_time=think_time,
hover=hover,
tab_switch=tab_switch,
include_iframes=include_iframes,
)
if not html:
return "❌ 抓取失败:目标网站可能拒绝访问、超时,或触发验证码(截图已存 data/screenshots/)。"
# 提取可见文本摘要
import re
text = re.sub(r"<[^>]+>", " ", html)
text = re.sub(r"\s+", " ", text).strip()
return f"✅ 类人抓取成功({len(html)} 字符)\n\n{text[:3000]}{'…' if len(text) > 3000 else ''}"
except Exception as e:
return f"❌ 浏览器抓取异常: {str(e)}"
@server.tool()
async def analyze_policy_document(url_or_path: str, keyword: str = "") -> str:
"""v4.7 下载并分析监管政策文件/公告(标题/日期/关键词命中/摘要)。
Args:
url_or_path: 政策文件 URL 或本地文件路径
keyword: 关注关键词(如 "利率"、"风险"),可多个用逗号分隔
"""
if _analyze_policy_document is None:
return _module_error("regulatory_scraper")
try:
r = _analyze_policy_document(url_or_path, keyword=keyword)
if "error" in r:
return f"❌ {r['error']}"
lines = [f"📄 {r.get('title', '')}"]
if r.get("date"):
lines.append(f" 日期: {r['date']}")
lines.append(f" 篇幅: {r.get('content_length', 0)} 字符")
if r.get("keywords_hit"):
lines.append(f" 命中关键词: {'、'.join(r['keywords_hit'])}")
if r.get("summary"):
lines.append(f" 摘要: {r['summary'][:400]}")
if r.get("saved_path"):
lines.append(f" 已保存: {r['saved_path']}")
return "\n".join(lines)
except Exception as e:
return f"❌ 分析政策文件失败: {str(e)}"
@server.tool()
async def search_policy_documents(keyword: str, agency: str = "all",
limit: int = 20) -> str:
"""v4.7 跨监管机构检索政策文件/公告。
Args:
keyword: 搜索关键词
agency: all / pboc / csrc / nfra
limit: 返回条数(默认 20)
"""
if _search_policy_documents is None:
return _module_error("regulatory_scraper")
try:
rows = _search_policy_documents(keyword, agency, limit)
if not rows:
return "🔍 未找到相关政策文件。"
lines = [f"📋 监管政策文件检索「{keyword}」({len(rows)} 条):"]
for r in rows[:15]:
doc_flag = "📎" if r.get("doc_url") else "🔗"
lines.append(f" {doc_flag} [{r.get('source', '')}] {r.get('title', '')[:70]}")
lines.append(f" {r.get('url', '')[:90]}")
return "\n".join(lines)
except Exception as e:
return f"❌ 检索政策文件失败: {str(e)}"
@server.tool()
async def crawl_policy_documents(agency: str = "all", limit: int = 10,
dry_run: bool = False) -> str:
"""v4.7 爬取并下载监管机构最新政策文件/公告(含内容分析)。
Args:
agency: all / pboc / csrc / nfra
limit: 下载数量上限(默认 10)
dry_run: True=仅预览不执行
"""
if dry_run:
return _dry_run_preview("监管政策爬取", {"agency": agency, "limit": limit})
if _crawl_policy_documents is None:
return _module_error("regulatory_scraper")
try:
r = _crawl_policy_documents(agency=agency, limit=limit)
lines = [f"🏛 监管政策爬取完成:发现 {r['total_found']} 篇,下载 {r['downloaded']} 篇"]
for doc in r["documents"][:10]:
saved = "✅" if doc.get("saved_path") else "⏭"
lines.append(f" {saved} [{doc.get('source', '')}] {doc.get('title', '')[:60]}")
if doc.get("saved_path"):
lines.append(f" {doc['saved_path']}")
for w in r.get("warnings", [])[:3]:
lines.append(f" ⚠️ {w}")
return "\n".join(lines)
except Exception as e:
return f"❌ 爬取政策文件失败: {str(e)}"
@server.tool()
async def compare_financial_data(series_json: str, date_key: str = "date",
value_fields: str = "") -> str:
"""v4.7 同类金融数据横向对比 + 数据质量回测。
传入 JSON 形式的 {"标签": [记录列表], ...},对多组同类数据做对比排名与回测评分。
Args:
series_json: JSON 字符串,如 {"贵州茅台": [{"date":"2026-07-31","margin_balance":100}], "五粮液": [...]}
date_key: 日期字段名(默认 date)
value_fields: 指定数值字段(逗号分隔,空=自动识别)
"""
if _compare_and_backtest is None or _format_comparison_report is None:
return _module_error("data_comparator")
try:
import json as _json
series = _json.loads(series_json)
if not isinstance(series, dict) or not series:
return "❌ series_json 需为非空 JSON 对象"
fields = [f.strip() for f in value_fields.split(",") if f.strip()] or None
result = _compare_and_backtest(series, date_key=date_key, value_fields=fields)
return _format_comparison_report(result)
except json.JSONDecodeError as e:
return f"❌ series_json 解析失败: {e}"
except Exception as e:
return f"❌ 对比回测失败: {str(e)}"
# ==================== v5.0.0 新增分析工具 ====================
@server.tool()
async def analyze_risk_metrics(nav_json: str, risk_free_rate: float = 0.02) -> str:
"""v5.0.0 产品净值风险指标分析(真实计算:年化收益/年化波动/最大回撤/夏普/卡玛)。
Args:
nav_json: 净值时间序列 JSON(升序,≥30 点才计算真实指标),
如 [{"date":"2026-01-01","nav":1.2}, {"date":"2026-01-02","nav":1.21}]
risk_free_rate: 无风险利率(默认 0.02 = 2%)
"""
try:
from analyzer import calculate_risk_metrics # type: ignore
except ImportError:
return _module_error("analyzer")
try:
navs = json.loads(nav_json)
if not isinstance(navs, list) or not navs:
return "❌ nav_json 需为非空 JSON 数组"
metrics = calculate_risk_metrics(navs, risk_free_rate=risk_free_rate)
return json.dumps(metrics, ensure_ascii=False, indent=2)
except json.JSONDecodeError as e:
return f"❌ nav_json 解析失败: {e}"
except Exception as e:
return f"❌ 风险指标计算失败: {str(e)}"
@server.tool()
async def analyze_product(url_or_data: str, product_type: str = "auto") -> str:
"""v5.0.0 完整产品分析报告(解析 + 真实风险指标 + 风格定位 + 复刻建议)。
Args:
url_or_data: 产品页面 URL,或产品数据 JSON 字符串(含 holdings/historical_nav 等)
product_type: 产品类型提示(基金/ETF/股票/债券,auto 自动判断)
"""
try:
from analyzer import analyze_product as _analyze_product # type: ignore
except ImportError:
return _module_error("analyzer")
try:
data = url_or_data.strip()
if data.startswith("{"):
product_info = json.loads(data)
else:
# 从 URL 解析(web_parser 动态导入)
try:
import importlib
wp = importlib.import_module("web_parser")
result = wp.parse_financial_product(data, product_type=product_type)
if hasattr(result, "to_dict"):
result = result.to_dict()
product_info = result if isinstance(result, dict) else {}
except Exception as e:
return f"❌ 页面解析失败: {e}"
return _analyze_product(product_info)
except json.JSONDecodeError as e:
return f"❌ 产品数据解析失败: {e}"
except Exception as e:
return f"❌ 产品分析失败: {str(e)}"
@server.tool()
async def analyze_portfolio_replication(product_json: str, limit: int = 5) -> str:
"""v5.0.0 组合复刻建议(数据驱动替代标的 + 数值距离相似产品推荐)。
Args:
product_json: 产品数据 JSON(含 product_name/holdings/historical_nav 等)
limit: 相似产品返回数量(默认 5)
"""
try:
from analyzer import generate_portfolio_replication # type: ignore
except ImportError:
return _module_error("analyzer")
try:
product = json.loads(product_json)
if not isinstance(product, dict):
return "❌ product_json 需为 JSON 对象"
rep = generate_portfolio_replication(product)
if limit and isinstance(rep.get("similar_products"), list):
rep["similar_products"] = rep["similar_products"][:limit]
return json.dumps(rep, ensure_ascii=False, indent=2, default=str)
except json.JSONDecodeError as e:
return f"❌ product_json 解析失败: {e}"
except Exception as e:
return f"❌ 组合复刻失败: {str(e)}"
@server.tool()
async def get_structured_data(data_type: str, params_json: str = "{}") -> str:
"""v5.0.0 结构化市场数据(期货/宏观/同业/港股美股,akshare 低代码零 token)。
Args:
data_type: futures_spot / futures_daily / futures_holding / macro_gdp /
macro_cpi / macro_ppi / macro_pmi / macro_fx_reserves /
shibor / repo_rate / hk_spot / us_spot
params_json: 可选参数 JSON(如 {"symbol":"V0"} / {"start_year":"2022"})
"""
try:
from structured_market_data import get_structured_data as _get, list_data_types # type: ignore
except ImportError:
return _module_error("structured_market_data")
try:
params = json.loads(params_json) if params_json else {}
result = _get(data_type, **params)
if result is None:
types = [t["data_type"] for t in list_data_types()]
return f"❌ 数据获取失败(akshare 未安装或接口不可用)。可用类型: {types}"
if hasattr(result, "to_dict"): # pandas.DataFrame
result = result.to_dict("records")
return _format_data_rows(result)
except json.JSONDecodeError as e:
return f"❌ params_json 解析失败: {e}"
except Exception as e:
return f"❌ 结构化数据获取失败: {str(e)}"
# ==================== v7.0.0 网页操作深度增强 + 机构名单扩充 ====================
@server.tool()
async def discover_api(url: str, wait_seconds: float = 6.0,
url_pattern: str = "") -> str:
"""v7.0.0 网络拦截 API 挖掘:打开页面并监听 XHR/fetch,捕获背后的 JSON 接口。
自动识别分页规律并沉淀到 data/api_registry.json(去重累积,可复用)。
对挖掘东财式"页面背后接口"价值最大——发现后无需浏览器即可高速直连。
Args:
url: 目标页面(SPA 动态页优先)
wait_seconds: 页面就绪后的继续监听时长(默认 6s,触发懒加载请求)
url_pattern: 只保留匹配该正则的请求(空=不过滤)
"""
try:
from browser_pro import discover_api as _disc # type: ignore
except ImportError:
try:
from scripts.browser_pro import discover_api as _disc # type: ignore
except ImportError:
return _module_error("browser_pro")
try:
result = await asyncio.to_thread(_disc, url, wait_seconds, url_pattern)
if not result:
return ("❌ API 挖掘失败:Playwright 未安装或页面打不开。安装:\n"
" pip install playwright && playwright install chromium")
lines = [f"🔍 捕获 {result['captured_count']} 个 XHR/fetch 请求,"
f"新入库接口 {result['registry_saved']} 个"]
for pg in result.get("pagination", [])[:5]:
lines.append(f" 📄 分页: {pg['url_template']} 参数={pg['pagination_param']} "
f"步长={pg['step']} 出现{pg['times_seen']}次")
for api in result.get("apis", [])[:15]:
mark = "✓JSON" if "sample_response" in api else " "
lines.append(f" {mark} [{api['status']}] {api['method']} {api['url'][:130]}")
lines.append("完整结果已存 data/api_registry.json(ApiRegistry.search 可查询复用)")
return "\n".join(lines)
except Exception as e:
return f"❌ API 挖掘异常: {str(e)}"
@server.tool()
async def play_macro(macro_name: str, params_json: str = "{}") -> str:
"""v7.0.0 回放网页操作宏(JSON 操作序列:goto/click/fill/press/upload/download/drag/extract…)。
宏文件放 macros/ 目录;支持 {{参数}} 占位符替换。
Args:
macro_name: 宏名(macros/ 下的文件名,带不带 .json 均可)
params_json: 参数 JSON(如 {"keyword":"贵州茅台"})
"""
try:
from browser_pro import play_macro as _play # type: ignore
except ImportError:
try:
from scripts.browser_pro import play_macro as _play # type: ignore
except ImportError:
return _module_error("browser_pro")
try:
params = json.loads(params_json) if params_json else {}
result = await asyncio.to_thread(_play, macro_name, params)
if not result.get("ok"):
return f"❌ 宏回放失败: {result.get('error', '未知错误')}"
outputs = {k: v for k, v in (result.get("outputs") or {}).items()
if k != "_done"}
summary = []
for k, v in outputs.items():
text = v if isinstance(v, str) else json.dumps(v, ensure_ascii=False,
default=str)
summary.append(f" [{k}] {text[:400]}{'…' if len(text) > 400 else ''}")
return (f"✅ 宏回放成功({result['steps_done']}/{result['steps_total']} 步,"
f"产出 {len(summary)} 项)\n" + "\n".join(summary))
except json.JSONDecodeError as e:
return f"❌ params_json 解析失败: {e}"
except Exception as e:
return f"❌ 宏回放异常: {str(e)}"
@server.tool()
async def browser_upload(url: str, selector: str, file_paths_json: str,
submit_selector: str = "") -> str:
"""v7.0.0 文件上传:打开页面 → 选择文件 → (可选)点提交。
Args:
url: 目标页面 URL
selector: input[type=file] 的 CSS 选择器
file_paths_json: 本地文件路径 JSON 数组,如 ["C:/tmp/a.pdf"]
submit_selector: 提交按钮选择器(空=不点击)
"""
try:
from browser_pro import MacroPlayer # type: ignore
except ImportError:
try:
from scripts.browser_pro import MacroPlayer # type: ignore
except ImportError:
return _module_error("browser_pro")
try:
files = json.loads(file_paths_json)
macro = {"steps": [{"action": "goto", "url": url},
{"action": "upload", "selector": selector,
"files": files}]}
if submit_selector:
macro["steps"].append({"action": "click", "selector": submit_selector})
macro["steps"].append({"action": "wait", "seconds": 2})
macro["steps"].append({"action": "extract", "as": "html"})
result = await asyncio.to_thread(
MacroPlayer(headless=True).play, macro)
ok = result.get("ok")
html = (result.get("outputs") or {}).get("html", "")
return (f"{'✅' if ok else '❌'} 上传{'成功' if ok else '失败'}"
f"({len(files)} 个文件),提交后页面 {len(html)} 字符")
except Exception as e:
return f"❌ 上传异常: {str(e)}"
@server.tool()
async def browser_download(url: str, click_selector: str = "",
filename: str = "") -> str:
"""v7.0.0 文件下载:打开页面 → 点击触发下载(或直接下载 url)→ 落盘到 data/downloads/。
Args:
url: 目标页面 URL(或直链文件地址)
click_selector: 触发下载的按钮选择器(空=直接从 url 下载)
filename: 保存文件名(空=用服务端建议名)
"""
try:
from browser_scraper import _get_browser_scraper # type: ignore
except ImportError:
try:
from scripts.browser_scraper import _get_browser_scraper # type: ignore
except ImportError:
return _module_error("browser_scraper")
try:
def _do():
bs = _get_browser_scraper(headless=True)
return bs.download_file(url, click_selector=click_selector,
filename=filename) if bs else None
result = await asyncio.to_thread(_do)
return (f"✅ 下载成功: {result['path']}" if result
else "❌ 下载失败(选择器未触发下载或超时)")
except Exception as e:
return f"❌ 下载异常: {str(e)}"
@server.tool()
async def async_batch_fetch(urls_json: str, concurrency: int = 4) -> str:
"""v7.0.0 异步并发批量抓取:async Playwright 多标签页池,比逐个抓取快数倍。
Args:
urls_json: URL 列表 JSON,如 ["https://a.com", "https://b.com"]
concurrency: 并发标签页数(默认 4,建议 ≤6)
"""
try:
from browser_pro import async_batch_fetch as _batch # type: ignore
except ImportError:
try:
from scripts.browser_pro import async_batch_fetch as _batch # type: ignore
except ImportError:
return _module_error("browser_pro")
try:
urls = json.loads(urls_json)
if not isinstance(urls, list) or not urls:
return "❌ urls_json 必须是非空 URL 数组"
results = await asyncio.to_thread(_batch, urls[:30], concurrency)
if results is None:
return ("❌ Playwright 未安装。安装:\n"
" pip install playwright && playwright install chromium")
ok = sum(1 for _, h in results if h)
lines = [f"⚡ 并发抓取完成:{ok}/{len(results)} 成功(并发 {concurrency})"]
for u, h in results:
lines.append(f" {'✅' if h else '❌'} {u[:100]} "
f"({len(h) if h else 0} 字符)")
return "\n".join(lines)
except Exception as e:
return f"❌ 并发抓取异常: {str(e)}"
@server.tool()
async def expand_institutions(only: str = "", dry_run: bool = False) -> str:
"""v7.0.0 机构名单扩充:多源合并(实时接口→精选名录→既有名单)补全金融机构名单。
新增民营银行/外资法人银行/AMC/村镇银行/保险中介等类型,去重后并入
data/institution_registry.json 并同步 *_list.json 与舆情目标计数。
Args:
only: 只处理某类别(如 fund_company,空=全部)
dry_run: 只统计不写文件(默认 False)
"""
try:
from institution_expander import run as _run # type: ignore
except ImportError:
try:
from scripts.institution_expander import run as _run # type: ignore
except ImportError:
return _module_error("institution_expander")
try:
report = await asyncio.to_thread(_run, dry_run, only)
lines = [f"🏛 名单扩充{'(dry-run)' if dry_run else ''}完成:"]
for cat, info in report.items():
lines.append(f" {info['type']}: +{info['added']} → 共 {info['total']}"
f"({info['source']})")
return "\n".join(lines) if report else "ℹ️ 无新增数据(精选名录未就绪或已全部合并)"
except Exception as e:
return f"❌ 名单扩充异常: {str(e)}"
@server.tool()
async def complete_institution_urls(limit: int = 30, type_name: str = "") -> str:
"""v7.0.0 机构官网补全:多引擎搜索+可达性验证,为无官网机构批量回填 URL(断点续跑)。
Args:
limit: 本次处理上限(默认 30,建议 ≤50 避免搜索引擎限流)
type_name: 只处理该类型(如 期货公司,空=全部缺 URL 机构)
"""
try:
from url_completer import UrlCompleter # type: ignore
except ImportError:
try:
from scripts.url_completer import UrlCompleter # type: ignore
except ImportError:
return _module_error("url_completer")
try:
def _do():
return UrlCompleter().run(limit=limit, type_filter=type_name)
stats = await asyncio.to_thread(_do)
return (f"🔗 官网补全完成:尝试 {stats['tried']},成功 {stats['filled']},"
f"验证失败 {stats['unverified']},过滤聚合/敏感域 "
f"{stats['skipped_blocked']}(进度已存档,支持断点续跑)")
except Exception as e:
return f"❌ 官网补全异常: {str(e)}"
@server.tool()
async def update_stock_list() -> str:
"""v7.0.0 更新 A 股全量上市公司名单(东财多镜像→新浪双源容灾),
输出 data/listed_companies.json,供舆情 listed_company 目标匹配。"""
try:
from stock_list_updater import StockListUpdater # type: ignore
except ImportError:
try:
from scripts.stock_list_updater import StockListUpdater # type: ignore
except ImportError:
return _module_error("stock_list_updater")
try:
result = await asyncio.to_thread(StockListUpdater().run)
if result.get("success"):
return f"✅ 名单更新成功:{result['count']} 只(来源: {result['source']})"
return "❌ 更新失败:双源均不可用(东财限流且新浪失败),请稍后重试"
except Exception as e:
return f"❌ 名单更新异常: {str(e)}"
# ==================== v7.1.0 定期报告分析解读 ====================
@server.tool()
async def interpret_financial_report(stock_code: str,
period_limit: int = 8) -> str:
"""v7.1.0 定期报告分析解读:自动拉取上市公司财务数据并生成自然语言解读。
覆盖业绩概览(营收/净利/同比/环比/EPS)、盈利能力(ROE/毛利率)、
现金流质量、亮点信号、风险提示、综合评分与评级建议(纯规则引擎)。
Args:
stock_code: 股票代码(600519 / 000001.SZ / 832566.BJ)
period_limit: 拉取报告期数(默认 8,用于趋势判断)
"""
try:
from report_interpreter import interpret_stock # type: ignore
except ImportError:
try:
from scripts.report_interpreter import interpret_stock # type: ignore
except ImportError:
return _module_error("report_interpreter")
try:
result = await asyncio.to_thread(interpret_stock, stock_code, period_limit)
text = result.get("text")
if text:
return text
return f"❌ 解读失败:{result.get('error', '未知错误')}"
except Exception as e:
return f"❌ 解读异常: {str(e)}"
# ==================== v7.3.0 爬取增强 / 内容识别 / 汇总分析 ====================
@server.tool()
async def smart_crawl_batch(urls: list, concurrency: int = 4,
use_browser: bool = False) -> str:
"""v7.3.0 智能批量爬取:多级降级(API直连→HTTP→浏览器)+ 域名限速 +
失败分类(软拦截/超时/空页)+ 成功率统计。
Args:
urls: 待爬 URL 列表(建议单次 ≤30,并发 ≤5)
concurrency: 并发数(默认 4)
use_browser: 是否允许浏览器兜底(需 Playwright,默认关)
"""
try:
from crawl_enhancer import batch_smart_crawl # type: ignore
except ImportError:
try:
from scripts.crawl_enhancer import batch_smart_crawl # type: ignore
except ImportError:
return _module_error("crawl_enhancer")
try:
out = await asyncio.to_thread(batch_smart_crawl, list(urls),
concurrency, None, None, use_browser)
lines = []
for fr in out["results"][:50]:
lines.append(f"[{fr.status}] {fr.source or '-'} "
f"{fr.text_len}B {fr.url}")
import json as _json
lines.append("")
lines.append("统计: " + _json.dumps(out["stats"], ensure_ascii=False))
return "\n".join(lines)
except Exception as e:
return f"❌ 批量爬取异常: {str(e)}"
@server.tool()
async def recognize_webpage(url: str) -> str:
"""v7.3.0 网页内容识别:自动判别页面类型(列表/文章/数据页)并抽取
结构化内容(文章条目/表格/键值数据块/分页),供后续定向爬取。
Args:
url: 目标页面 URL(HTTP 抓取;动态页建议先用 discover_api 挖接口)
"""
try:
from web_recognizer import smart_extract # type: ignore
from http_utils import http_get # type: ignore
except ImportError:
try:
from scripts.web_recognizer import smart_extract # type: ignore
from scripts.http_utils import http_get # type: ignore
except ImportError:
return _module_error("web_recognizer")
try:
resp = await asyncio.to_thread(http_get, url)
if resp is None:
return f"❌ 页面抓取失败: {url}"
import json as _json
result = smart_extract(resp.text, url=url)
result.pop("text", None)
for k in ("articles", "tables", "numbers"):
if isinstance(result.get(k), list):
result[k] = result[k][:20]
return _json.dumps(result, ensure_ascii=False, indent=1)[:6000]
except Exception as e:
return f"❌ 识别异常: {str(e)}"
@server.tool()
async def aggregate_sentiment(days: int = 0,
output_path: str = "") -> str:
"""v7.3.0 舆情汇总分析:聚合 data/sentiment_snapshots/ 全部快照,
跨源模糊去重,输出情感/严重度/时间/来源分布 + 热词 + 机构截面,
可选导出 Markdown 汇总报告。
Args:
days: 仅统计最近 N 天(0=全部)
output_path: Markdown 报告落盘路径(空则仅返回摘要)
"""
try:
from data_aggregator import aggregate_snapshots, export_markdown # type: ignore
except ImportError:
try:
from scripts.data_aggregator import (aggregate_snapshots, # type: ignore
export_markdown)
except ImportError:
return _module_error("data_aggregator")
try:
agg = await asyncio.to_thread(aggregate_snapshots, None, days)
md = export_markdown(agg, output_path or None)
if agg["raw_count"] == 0:
return "ℹ️ 暂无舆情快照可汇总(先跑 crawl_global_sentiment 生成快照)"
return md[:6000]
except Exception as e:
return f"❌ 汇总异常: {str(e)}"
# ==================== 主入口 ====================
def main():
"""MCP Server 入口 — 走 stdio 协议"""
server.run(transport="stdio")
if __name__ == '__main__':
main()