1+ import asyncio
2+ import json
3+ import os
4+ import random
5+ import uuid
6+ import pandas as pd
7+ from datetime import datetime
8+ from fastmcp .client .client import CallToolResult
9+ from loguru import logger
10+
11+ from finance_mcp .core .utils .fastmcp_client import FastMcpClient
12+
13+ # --- 配置区 ---
14+ HOST = "localhost"
15+ PORT = 8050
16+ CSV_PATH = "tushare_stock_basic_20251226104714.csv"
17+ BASE_CACHE_DIR = "tool_cache"
18+ PROGRESS_DIR = os .path .join (BASE_CACHE_DIR , "progress" )
19+ MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
20+ SAVE_BATCH_SIZE = 1 # 每1个保存一次
21+ MAX_CONCURRENCY = 5 # 最大并发数(信号量控制,设为1即串行)
22+ MIN_WAIT_ON_EMPTY = 60 # 无输出时最小等待秒数
23+ MAX_WAIT_ON_EMPTY = 120 # 无输出时最大等待秒数
24+ NORMAL_WAIT_SECONDS = 2 # 正常请求间隔秒数
25+
26+ # 针对每个页面结构设计的全量提取 Query
27+ TOOLS_CONFIG = [
28+ # ("crawl_ths_company", "提取公司的完整资料:1.基本信息(行业、产品、主营、办公地址);2.高管介绍(所有高管的姓名、职务、薪资、详细个人简历);3.发行相关(上市日期、首日表现、募资额);4.所有参控股公司的名称、持股比例、业务、盈亏情况。"),
29+ ("crawl_ths_holder" , "提取股东研究全量数据:1.历年股东人数及户均持股数;2.前十大股东及流通股东名单(含持股数、性质、变动情况);3.实际控制人详情及控股层级关系描述;4.股权质押、冻结的详细明细表。" ),
30+ ("crawl_ths_operate" , "提取经营分析数据:1.主营构成分析表(按行业、产品、区域划分的营业收入、利润、毛利率及同比变化);2.经营评述(公司对业务、核心竞争力的详细自我评估)。" ),
31+ ("crawl_ths_equity" , "提取股本结构信息:1.历次股本变动原因、日期及变动后的总股本;2.限售股份解禁的时间表、解禁数量及占总股本比例。" ),
32+ ("crawl_ths_capital" , "提取资本运作详情:1.资产重组、收购、合并的详细历史记录;2.对外投资明细及进展情况。" ),
33+ ("crawl_ths_worth" , "提取盈利预测信息:1.各机构最新评级汇总(买入/增持次数);2.未来三年的营收预测、净利润预测及EPS预测均值。" ),
34+ ("crawl_ths_news" , "提取最新新闻公告:1.公司最新重要公告标题及日期;2.媒体报道的新闻摘要及舆情评价。" ),
35+ ("crawl_ths_concept" , "提取所有概念题材:列出公司所属的所有概念板块,并详细提取每个概念对应的具体入选理由和业务关联性。" ),
36+ ("crawl_ths_position" , "提取主力持仓情况:1.各类机构(基金、保险、QFII等)持仓总数及占比;2.前十大具体机构持仓名单及变动。" ),
37+ ("crawl_ths_finance" , "提取财务分析详情:1.主要财务指标(盈利、成长、偿债等);2.资产负债表、利润表、现金流量表的核心科目及审计意见。" ),
38+ ("crawl_ths_bonus" , "提取分红融资记录:1.历年现金分红、送转股份方案及实施日期;2.历次增发、配股等融资详情。" ),
39+ ("crawl_ths_event" , "提取公司大事记录:1.股东及高管持股变动明细;2.对外担保记录、违规处理、机构调研及投资者互动记录。" ),
40+ ("crawl_ths_field" , "提取行业对比数据:1.公司在所属行业内的规模、成长、盈利各项排名;2.与行业均值及同类竞品的关键财务指标对比。" )
41+ ]
42+
43+ if not os .path .exists (BASE_CACHE_DIR ):
44+ os .makedirs (BASE_CACHE_DIR , exist_ok = True )
45+ os .makedirs (PROGRESS_DIR , exist_ok = True )
46+
47+ class BatchResultSaver :
48+ def __init__ (self , tool_name ):
49+ self .tool_name = tool_name
50+ self .buffer = []
51+ self .file_index = 1
52+
53+ def _get_file_path (self ):
54+ return os .path .join (BASE_CACHE_DIR , f"{ self .tool_name } _{ self .file_index :02d} .json" )
55+
56+ def add_record (self , tool_args , result_text ):
57+ now = datetime .now ()
58+ record = {
59+ "_id" : str (uuid .uuid4 ()),
60+ "cache_key" : f"{ self .tool_name } ::{ json .dumps (tool_args , ensure_ascii = False )} " ,
61+ "created_at" : now .isoformat (),
62+ "metadata" : {"task_id" : "comprehensive_crawl" , "timestamp" : now .isoformat ()},
63+ "tool_args" : tool_args ,
64+ "tool_name" : self .tool_name ,
65+ "tool_result" : result_text ,
66+ "updated_at" : now .isoformat ()
67+ }
68+ self .buffer .append (record )
69+ if len (self .buffer ) >= SAVE_BATCH_SIZE :
70+ self .flush ()
71+
72+ def flush (self ):
73+ if not self .buffer : return
74+ file_path = self ._get_file_path ()
75+
76+ # 自动切分 50MB
77+ if os .path .exists (file_path ) and os .path .getsize (file_path ) > MAX_FILE_SIZE :
78+ self .file_index += 1
79+ file_path = self ._get_file_path ()
80+
81+ data = []
82+ if os .path .exists (file_path ):
83+ with open (file_path , "r" , encoding = "utf-8" ) as f :
84+ try : data = json .load (f )
85+ except : data = []
86+
87+ data .extend (self .buffer )
88+ with open (file_path , "w" , encoding = "utf-8" ) as f :
89+ json .dump (data , f , ensure_ascii = False , indent = 2 )
90+
91+ logger .info (f"Save: { file_path } | Count: { len (data )} " )
92+ self .buffer = []
93+
94+ class ProgressTracker :
95+ """进度跟踪器,支持断点继续"""
96+ def __init__ (self , tool_name ):
97+ self .tool_name = tool_name
98+ self .progress_file = os .path .join (PROGRESS_DIR , f"{ tool_name } _progress.json" )
99+ self .completed_codes = set ()
100+ self .time_records = {} # 记录每个code的处理时间
101+ self .load_progress ()
102+
103+ def load_progress (self ):
104+ if os .path .exists (self .progress_file ):
105+ try :
106+ with open (self .progress_file , "r" , encoding = "utf-8" ) as f :
107+ data = json .load (f )
108+ self .completed_codes = set (data .get ("completed_codes" , []))
109+ self .time_records = data .get ("time_records" , {})
110+ except Exception as e :
111+ logger .warning (f"加载进度文件失败 { self .progress_file } : { e } " )
112+ self .completed_codes = set ()
113+ self .time_records = {}
114+
115+ def save_progress (self ):
116+ try :
117+ data = {
118+ "completed_codes" : list (self .completed_codes ),
119+ "time_records" : self .time_records
120+ }
121+ with open (self .progress_file , "w" , encoding = "utf-8" ) as f :
122+ json .dump (data , f , ensure_ascii = False , indent = 2 )
123+ except Exception as e :
124+ logger .error (f"保存进度文件失败 { self .progress_file } : { e } " )
125+
126+ def mark_completed (self , code , elapsed_time = None ):
127+ self .completed_codes .add (code )
128+ if elapsed_time is not None :
129+ self .time_records [code ] = {
130+ "elapsed_seconds" : round (elapsed_time , 2 ),
131+ "completed_at" : datetime .now ().isoformat ()
132+ }
133+
134+ def is_completed (self , code ):
135+ return code in self .completed_codes
136+
137+ def get_remaining_codes (self , all_codes ):
138+ return [code for code in all_codes if not self .is_completed (code )]
139+
140+ async def process_single_stock (client , tool_name , code , deep_query , saver , progress_tracker , semaphore , index , total ):
141+ """处理单个股票的异步任务"""
142+ async with semaphore : # 信号量控制并发数
143+ args = {"code" : code , "query" : deep_query }
144+ start_time = datetime .now () # 记录开始时间
145+ try :
146+ logger .info (f"Calling { tool_name } for { code } ({ index } /{ total } )" )
147+ result : CallToolResult = await client .call_tool (tool_name , args )
148+ elapsed_time = (datetime .now () - start_time ).total_seconds () # 计算耗时
149+
150+ if not result .is_error :
151+ content = result .content [0 ].text if result .content else ""
152+
153+ # 检查内容是否包含不当内容警告,直接跳过不保存
154+ if "Input data may contain inappropriate content" in content :
155+ logger .warning (f"工具 { tool_name } 处理 { code } 返回不当内容警告, 跳过该记录" )
156+ return
157+
158+ # 检查内容是否为空或太短,如果是则等待50-100秒
159+ if not content or len (content .strip ()) < 10 :
160+ wait_seconds = random .randint (MIN_WAIT_ON_EMPTY , MAX_WAIT_ON_EMPTY )
161+ logger .warning (
162+ f"工具 { tool_name } 处理 { code } 无输出或输出过短, "
163+ f"耗时 { elapsed_time :.2f} 秒, 等待 { wait_seconds } 秒..."
164+ )
165+ await asyncio .sleep (wait_seconds )
166+ else :
167+ # 有正常输出,保存记录
168+ saver .add_record (args , content )
169+ # 标记该代码已完成,记录耗时
170+ progress_tracker .mark_completed (code , elapsed_time )
171+ # 定期保存进度
172+ if index % SAVE_BATCH_SIZE == 0 :
173+ progress_tracker .save_progress ()
174+ # 正常等待间隔
175+ logger .info (f"成功处理 { code } , 耗时 { elapsed_time :.2f} 秒, 等待 { NORMAL_WAIT_SECONDS } 秒..." )
176+ await asyncio .sleep (NORMAL_WAIT_SECONDS )
177+ else :
178+ error_msg = result .content [0 ].text [:100 ] if result .content else "No error message"
179+ logger .error (f"Error { code } : { error_msg } , 耗时 { elapsed_time :.2f} 秒" )
180+ except Exception as e :
181+ import traceback
182+ elapsed_time = (datetime .now () - start_time ).total_seconds ()
183+ logger .error (f"Failed { code } : { e } , 耗时 { elapsed_time :.2f} 秒" )
184+ logger .error (f"Traceback: { traceback .format_exc ()} " )
185+
186+ async def test_mcp_service ():
187+ df = pd .read_csv (CSV_PATH , dtype = {'symbol' : str })
188+ stock_codes = df ['symbol' ].dropna ().apply (lambda x : str (x ).zfill (6 )).tolist ()
189+
190+ mcp_config = {"type" : "sse" , "url" : f"http://{ HOST } :{ PORT } /sse" }
191+
192+ # 信号量控制并发数
193+ semaphore = asyncio .Semaphore (MAX_CONCURRENCY )
194+
195+ async with FastMcpClient (name = "full-info-crawler" , config = mcp_config ) as client :
196+ # 外层循环:工具 (及设计好的深度 Query)
197+ for tool_name , deep_query in TOOLS_CONFIG :
198+ logger .info (f"### 开始爬取工具: { tool_name } " )
199+ saver = BatchResultSaver (tool_name )
200+ progress_tracker = ProgressTracker (tool_name )
201+
202+ # 获取剩余需要处理的股票代码
203+ remaining_codes = progress_tracker .get_remaining_codes (stock_codes )
204+ total_remaining = len (remaining_codes )
205+ logger .info (f"### 工具 { tool_name } 剩余 { total_remaining } 个股票代码待处理" )
206+
207+ # 创建所有并发任务
208+ tasks = []
209+ for i , code in enumerate (remaining_codes , start = 1 ):
210+ task = process_single_stock (
211+ client = client ,
212+ tool_name = tool_name ,
213+ code = code ,
214+ deep_query = deep_query ,
215+ saver = saver ,
216+ progress_tracker = progress_tracker ,
217+ semaphore = semaphore ,
218+ index = i ,
219+ total = total_remaining
220+ )
221+ tasks .append (task )
222+
223+ # 并发执行所有任务,信号量控制最多10个同时运行
224+ logger .info (f"启动 { len (tasks )} 个并发任务,信号量限制为 { MAX_CONCURRENCY } " )
225+ await asyncio .gather (* tasks )
226+
227+ # 保存最后的进度
228+ progress_tracker .save_progress ()
229+ saver .flush () # 一个工具所有代码跑完,冲刷最后的数据
230+ logger .info (f"### 工具 { tool_name } 完成!" )
231+
232+ if __name__ == "__main__" :
233+ asyncio .run (test_mcp_service ())
0 commit comments