# -*- coding: utf-8 -*- """ 全网舆情爬虫 — 对话入口助手 v4.3 ================================== 让用户通过自然语言触发舆情爬取 / 导出 / 定时任务,无需记忆命令。 触发示例: "帮我爬一下贵州茅台最近7天的舆情" "工银瑞信最近3天的负面新闻" "看下华夏基金今天的正面新闻并导出 Excel" "每天早上9点爬取银行板块舆情" "增加自定义目标:恒生电子" """ from __future__ import annotations import hashlib import json import re import time from pathlib import Path from typing import Dict, Any, List, Optional, Tuple from datetime import datetime _import_error = None try: from .sentiment_crawler import ( crawl_sentiment, SentimentCrawler, list_sentiment_targets, list_sentiment_sources, add_custom_sentiment_target, SentimentSourceLoader, SentimentTargetLoader, ) from .sentiment_exporter import to_dialog from .sentiment_keywords import POSITIVE_KEYWORDS, NEGATIVE_KEYWORDS except (ImportError, ValueError): try: from sentiment_crawler import ( crawl_sentiment, SentimentCrawler, list_sentiment_targets, list_sentiment_sources, add_custom_sentiment_target, SentimentSourceLoader, SentimentTargetLoader, ) from sentiment_exporter import to_dialog from sentiment_keywords import POSITIVE_KEYWORDS, NEGATIVE_KEYWORDS except ImportError as e: _import_error = str(e) crawl_sentiment = SentimentCrawler = None list_sentiment_targets = list_sentiment_sources = None SentimentTargetLoader = None add_custom_sentiment_target = None to_dialog = None POSITIVE_KEYWORDS = set() NEGATIVE_KEYWORDS = set() # ================================================================ # 0. v4.5 爬取前确认 — 指纹/缓存/格式化 # ================================================================ CONFIRM_FILE = Path(__file__).parent.parent / "data" / "sentiment_confirmations.json" def _compute_fingerprint(targets: List[str], source_categories: List[str], days: int) -> str: """计算 (targets, categories, days) 的指纹。""" key = json.dumps({ "targets": sorted(targets or []), "cats": sorted(source_categories or []), "days": days, }, ensure_ascii=False, sort_keys=True) return hashlib.sha256(key.encode("utf-8")).hexdigest()[:16] def _is_confirmed_recently(fingerprint: str, ttl_hours: int = 24) -> bool: """检查该指纹在 ttl_hours 小时内是否已被确认过。""" data = _load_confirmations() entry = data.get(fingerprint) if not entry: return False ts = entry.get("confirmed_at", 0) return (time.time() - ts) < ttl_hours * 3600 def _mark_confirmed(fingerprint: str) -> None: """记录一次确认。""" data = _load_confirmations() data[fingerprint] = { "confirmed_at": time.time(), "snapshot_id": "", } try: CONFIRM_FILE.parent.mkdir(parents=True, exist_ok=True) CONFIRM_FILE.write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8", ) except Exception: pass # 容错 def _load_confirmations() -> Dict[str, Any]: if not CONFIRM_FILE.exists(): return {} try: return json.loads(CONFIRM_FILE.read_text(encoding="utf-8")) except Exception: return {} def _format_plan_reply(plan: Dict[str, Any]) -> str: """把 plan 渲染成可读中文。""" lines = ["🗂️ 爬取计划(预览,未执行)", ""] targets = plan.get("targets", []) if targets: lines.append("📌 目标机构") for t in targets[:10]: lines.append(f" · {t.get('name', '')} [{t.get('category', '')}]") if len(targets) > 10: lines.append(f" · ... 共 {len(targets)} 个") lines.append("") sources = plan.get("sources", []) if sources: lines.append(f"📰 媒体源({len(sources)} 家)") # 按类别分组 types: Dict[str, int] = {} for s in sources: t = s.get("type", "other") or "other" types[t] = types.get(t, 0) + 1 for t, n in sorted(types.items()): lines.append(f" · {t}: {n}") lines.append("") lines.append(f"⏱️ 时间窗口:最近 {plan.get('days', 7)} 天") lines.append(f"📊 预计文章:~{plan.get('estimated_articles', '?')} 条") lines.append(f"⏳ 预计耗时:~{plan.get('estimated_seconds', '?')} 秒") lines.append("") lines.append("[确认] 回复 go / y / 开始 / 确认 / 是") lines.append("[修改] 例:把窗口改成 3 天 / 只看负面 / 加上东方财富") return "\n".join(lines) # ================================================================ # 1. 简易 NLU — 关键词 + 正则 + 行业黑话 # ================================================================ class SentimentChatParser: """对话式 NLU — 解析用户自然语言到 API 参数""" def parse(self, text: str) -> Dict[str, Any]: """返回字典 {intent, params}""" text = (text or "").strip() intent = self._detect_intent(text) params: Dict[str, Any] = {} if intent in ("crawl", "crawl_export"): params = self._parse_crawl(text, with_export=(intent == "crawl_export")) elif intent == "schedule": params = self._parse_schedule(text) elif intent == "add_target": params = self._parse_add_target(text) elif intent == "list": params = self._parse_list(text) elif intent == "help": params = {} else: intent = "help" return {"intent": intent, "params": params, "raw": text} # ---------- Intent 识别 ---------- def _detect_intent(self, text: str) -> str: t = text.lower() # 添加自定义目标 if re.search(r"(新增|添加|加入|新建).{0,8}(目标|机构|公司|基金)", t) or \ re.search(r"add.{0,6}target", t): return "add_target" # 列表/查 (在定时前优先 — 「有哪些目标」等等) if re.search(r"(哪些媒体|有什么媒体|有哪些媒体|哪些源|媒体源|目标库|金融机构列表|有哪些目标|有哪些机构|支持的.{0,4}机构)", t): return "list" # 定时 if re.search(r"(每天|每周|每月|定时|每隔|每小时|每.{1,3}分钟|设置.{0,4}任务)", t) or \ re.search(r"schedule|cron", t): return "schedule" # 是否包含导出/生成文件类词 → crawl_export has_export = bool(re.search( r"(导出|生成|做|导出到).*?(word|excel|docx|xlsx|csv|报告|表格|文件)", t)) # 爬/舆情/资讯 — 拓宽到 0-12 字符 if re.search(r"(爬|获取|看看|查|搜索|搜索一下|导出|生成|整理).{0,12}(舆情|新闻|资讯|舆论|消息|报道)", t): return "crawl_export" if has_export else "crawl" if re.search(r"(正能量|正面|利多|利好|利空|负面|舆情|批评)", t) and \ re.search(r"(信息|新闻|报道)", t): return "crawl_export" if has_export else "crawl" # 直接出现舆情/新闻 + 动作词也视为爬取 if re.search(r"舆情|新闻|资讯|报道", t) and re.search(r"爬|获取|看|查|分析|查证", t): return "crawl_export" if has_export else "crawl" # 「看看/查查 + 实体 + 时间/政策」也是爬取 if re.search(r"(帮我)?(看看|看下|爬一下|爬|搜索)(.{2,30})", t) and re.search(r"过去|最近|今天|板块|新闻|政策|公告|舆情|媒体", t): return "crawl_export" if has_export else "crawl" # 新增: "XXX的舆情/新闻/评价" 直接触发 if re.search(r"(.{2,20})的(舆情|新闻|资讯|消息|风评|口碑|评价|报道)", t): return "crawl_export" if has_export else "crawl" return "help" # ---------- 参数抽取 ---------- def _parse_crawl(self, text: str, with_export: bool = False) -> Dict[str, Any]: params: Dict[str, Any] = {} # 时间窗 m = re.search(r"(最近|近|过去)?(\d+)\s*天", text) params["days"] = int(m.group(2)) if m else 7 mh = re.search(r"(\d+)\s*小时", text) if mh: params["days"] = max(1, int(int(mh.group(1)) // 24 + (1 if int(mh.group(1)) % 24 else 0))) # v4.6: 月份识别 — "7月" → 覆盖整个7月 mm = re.search(r"(?:(\d{4})\s*年\s*)?(\d{1,2})\s*月(?:份)?(?:\s*数据)?", text) if mm: month = int(mm.group(2)) if mm.group(2) else None year = int(mm.group(1)) if mm.group(1) else None if month and 1 <= month <= 12: now = datetime.now() if year is None: year = now.year if month <= now.month else now.year - 1 target_start = datetime(year, month, 1) if month == 12: target_end = datetime(year + 1, 1, 1) else: target_end = datetime(year, month + 1, 1) # 该月到现在的天数 if target_end > now: target_end = now days_to_cover = (target_end - target_start).days + 1 # 如果用户指定了"7月"而现在是7月30日, 覆盖整个7月=30天 params["days"] = max(days_to_cover, 1) params["date_hint"] = f"{year}年{month}月" params["max_articles"] = 60 # 情感筛选 if re.search(r"(负面|舆情|利空|批评|投诉|违规|风险)", text): params["negative_only"] = True elif re.search(r"(正面|利好|好消息|正能量)", text): params["positive_only"] = True else: params["positive_only"] = False params["negative_only"] = False # 目标:从目标库里模糊匹配 params["targets"] = self._extract_targets(text) # 媒体类别 params["source_categories"] = self._extract_source_categories(text) # 导出格式 params["export"] = "dialog" if with_export: if re.search(r"word", text, re.I): params["export"] = "word" elif re.search(r"excel|xlsx|表格", text, re.I): params["export"] = "excel" elif re.search(r"csv", text, re.I): params["export"] = "csv" elif re.search(r"json", text, re.I): params["export"] = "json" elif re.search(r"(全部|所有格式|多格式)", text): params["export"] = "all" else: params["export"] = "all" return params def _parse_schedule(self, text: str) -> Dict[str, Any]: params: Dict[str, Any] = {} # 频率 if re.search(r"每小时", text) or re.search(r"每小时一次", text): params["frequency"] = "hourly" elif re.search(r"每(?:天|日)", text) or re.search(r"早上.{0,4}(9|十)", text): params["frequency"] = "daily" elif re.search(r"每周", text): params["frequency"] = "weekly" elif re.search(r"每月", text): params["frequency"] = "monthly" elif (m := re.search(r"每\s*(\d+)\s*分钟", text)): interval = int(m.group(1)) params["frequency"] = {5: "every_5_minutes", 10: "every_10_minutes", 30: "every_30_minutes", 1: "every_minute"}.get(interval, "every_30_minutes") elif (m := re.search(r"每\s*(\d+)\s*小时", text)): interval = int(m.group(1)) params["frequency"] = {6: "every_6_hours", 12: "every_12_hours"}.get(interval, "hourly") else: params["frequency"] = "daily" # 完整爬取参数(先去除频率/动作修饰词) scrubbed = re.sub(r"(每天|每周|每月|每小时|每\d+小时|每\d+分钟|定时|自动|设置.{0,4}任务|爬取|爬|搜索|拉一下)", " ", text) crawl_params = self._parse_crawl(scrubbed) params.update({k: crawl_params[k] for k in ("targets", "days", "negative_only", "positive_only", "source_categories") if k in crawl_params}) params["action"] = "crawl_sentiment_export" params["export_format"] = crawl_params.get("export", "all") return params def _parse_add_target(self, text: str) -> Dict[str, Any]: # 先看显式「目标 工银瑞信」或「目标:工银瑞信」或「目标=工银瑞信」 m = re.search(r"(?:新增|添加|加入|新建)(?:自定义)?(?:目标|机构|公司|基金)?\s*[:::=]?\s*([^,。、\s,]+?)(?:\s|$)", text) name = (m.group(1).strip() if m else "") # 如果 (默认 fallback 取错),从剩余 token 过滤 if not name or name in {"自定义", "目标", "机构", "公司", "基金"}: # 取所有名词短语中的第一个 for tok in re.split(r"[,。、\s,]", text): tok = tok.strip() if tok and tok not in {"帮我", "新增", "添加", "加入", "新建", "自定义", "目标", "机构", "公司", "基金"}: name = tok break category = "listed_company" # 默认 if re.search(r"基金", text): category = "fund_company" elif re.search(r"券商|证券", text): category = "securities" elif re.search(r"银行", text): category = "commercial_bank" elif re.search(r"保险", text): category = "insurance" elif re.search(r"信托", text): category = "trust_company" elif re.search(r"私募", text): category = "private_fund" return {"category": category, "name": name} def _parse_list(self, text: str) -> Dict[str, Any]: if re.search(r"(目标|机构|公司)", text): return {"what": "targets"} return {"what": "sources"} # ---------- 抽取目标 / 媒体类别 ---------- def _extract_targets(self, text: str) -> List[str]: """从文本抽取目标名 — 严格边界 + 后缀词 + 多实体切分。""" if SentimentTargetLoader is None: return [] loader = SentimentTargetLoader() all_items = loader.all_targets() matched: List[str] = [] # 预清洗 text_clean = re.sub(r"[,,。.!!??\"\"''《》()()\[\]【】]", " ", text) # 1) 优先精确匹配注册表里的实体名 (按长度倒序) for _cat, _label, name in sorted(all_items, key=lambda x: -len(x[2] or "")): if not name: continue if re.search(re.escape(name), text_clean): if name not in matched: matched.append(name) if matched: return matched[:8] # 2) 多实体切分:先按「、」「和」「与」「,」「,」切 multi = re.split(r"[、,,]|\s+(?:和|与|及、?以及)\s+", text_clean) # 然后对每个 token 再剥离前/后 修饰 prefixes = ("帮我", "请帮我", "看看", "看下", "查看", "了解", "爬", "爬一下", "获取", "搜索", "查", "查一下", "查查", "分析", "一下", "一下子") suffixes = ("最近", "今天", "过去", "以下", "舆情", "新闻", "资讯", "报道", "消息", "信息", "舆论", "公告", "金融政策", "政策", "正面", "负面", "板块", "项", "板块的", "行业", "时间", "最新", "的", "这", "那", "爬取一下", "一下") stop_words = {"信息", "新闻", "舆情", "资讯", "报道", "舆论", "负面", "正面", "消息", "政策", "板块", "金融政策", "项", "今天", "昨天", "过去", "最近", "所有", "全部"} for tok in multi: tok = tok.strip() if not tok: continue # 反复剥离前缀后缀 changed = True while changed: changed = False for pref in prefixes: if tok.startswith(pref): tok = tok[len(pref):].strip() changed = True for suf in suffixes: # suffix 是「字符+任意数字+字符」的形式,匹配「最近7天」「最近几天」等 if re.match(r"^" + re.escape(suf) + r"(?:\d+(?:天|小时|分钟|周|月))?$", tok): tok = "" break if tok.endswith(suf): tok = tok[: -len(suf)].strip() changed = True tok = tok.strip("的块板块项业,。.,;:;:") if not tok or len(tok) < 2 or len(tok) > 20: continue if tok in stop_words: continue if tok in POSITIVE_KEYWORDS or tok in NEGATIVE_KEYWORDS: continue if re.fullmatch(r"[的了是个在有要就也我你他她它们]+", tok): continue if tok not in matched: matched.append(tok) if len(matched) >= 8: break # 最终裁剪:把 "X最近Y天" "X过去Y天" "X今天" 合并 → 只保留 X cleaned: List[str] = [] for tok in matched: m = re.match(r"^(.*?)(最近|过去|近|今|近|这两天)(?:\s*\d+)?\s*(天|小时|分钟|周|月|分钟|个)?(\s*日)?\s*$", tok) if m and m.group(1): tok = m.group(1).strip() # 截掉尾部的时间/单字后缀 tok = re.sub(r"(最近|过去|今天|明天|昨天|前年|去年|近期|这个|那个|哪|几)\s*$", "", tok).strip() if not tok or len(tok) < 2: continue if tok not in cleaned: cleaned.append(tok) return cleaned[:5] def _extract_source_categories(self, text: str) -> List[str]: cats: List[str] = [] rules = { "authoritative": r"(权威|财经媒体|财联社|华尔街见闻|第一财经|财新)", "financial_vertical": r"(财经垂直|财联社|华尔街|第一财经|财新|垂直)", "self_media": r"(自媒|微博|微信|今日头条|百家号|雪球|股吧|抖音|自媒体)", "international": r"(国际|境外|路透|Bloomberg|彭博|WSJ|FT|尼康|Nikkei)", } for cat, pat in rules.items(): if re.search(pat, text): cats.append(cat) # 全媒体 if not cats and re.search(r"(全网|所有|全部媒体|所有媒体)", text): cats = ["authoritative", "financial_vertical", "self_media", "international"] # 默认 - 财经类 if not cats: cats = ["authoritative", "financial_vertical"] return cats # ================================================================ # 2. 顶层对话入口 # ================================================================ def chat_handle(text: str) -> Dict[str, Any]: """统一对话处理。返回 {intent, params, reply, snapshot_or_files}""" if crawl_sentiment is None: return { "intent": "help", "reply": ( "⚠️ sentiment_crawler 模块未能加载。\n" "解决方案:\n" " 1. 运行 python setup_env.py 安装依赖\n" " 2. 或手动安装: pip install -r requirements.txt\n" " 3. 确认当前目录包含 scripts/ 文件夹" ), "params": {}, "output": None, } parser = SentimentChatParser() parsed = parser.parse(text) intent = parsed["intent"] params = parsed["params"] if intent == "help": return { "intent": "help", "params": {}, "reply": show_help(), "output": None, } if intent == "list": what = params.get("what", "sources") if what == "targets": data = list_sentiment_targets() or [] return { "intent": "list_targets", "params": params, "reply": _format_targets_reply(data), "output": data, } else: data = list_sentiment_sources() or [] return { "intent": "list_sources", "params": params, "reply": _format_sources_reply(data), "output": data, } if intent == "add_target": if not params.get("name"): return {"intent": "add_target", "params": params, "reply": "⚠️ 未识别到目标名称,请尝试:新增目标 工银瑞信", "output": None} result = add_custom_sentiment_target(params["category"], params["name"]) ok = "✅" if result.get("ok") else "⚠️" return {"intent": "add_target", "params": params, "reply": f"{ok} 新增 {params['category']} - {params['name']}\n{json.dumps(result, ensure_ascii=False)}", "output": result} if intent in ("crawl", "crawl_export"): try: # v4.5 进度反馈:打印当前状态 targets = params.get("targets") or [] scats = params.get("source_categories") or [] # v4.5: 爬取前确认流程 user_text = (params.get("raw_text") or "").strip().lower() is_confirm_reply = user_text in ( "go", "y", "yes", "ok", "确认", "开始", "继续", "crawl", "是", "好的", "跑", "执行", "yep", "yeah", "确认爬取", ) fingerprint = _compute_fingerprint(targets, scats, params.get("days", 7)) skip_confirm = ( is_confirm_reply or params.get("skip_confirm", False) or _is_confirmed_recently(fingerprint, ttl_hours=24) ) if not skip_confirm: # 第一次:仅返回计划 snapshot = crawl_sentiment( targets=targets, source_categories=scats, days=params.get("days", 7), positive_only=params.get("positive_only", False), negative_only=params.get("negative_only", False), max_articles=params.get("max_articles", 60), confirmed=False, ) if snapshot and snapshot.plan: reply = _format_plan_reply(snapshot.plan) return { "intent": intent, "params": params, "reply": reply, "output": snapshot, "awaiting_confirmation": True, } # plan 失败 → fallback 真爬(容错,落到下面分支) # 已确认 → 真爬 if targets: print(f"\n⏳ 正在爬取 {', '.join(targets[:3])}{' 等' if len(targets)>3 else ''} 的舆情,媒体源: {', '.join(scats)}...") snapshot = crawl_sentiment( targets=targets, source_categories=scats, days=params.get("days", 7), positive_only=params.get("positive_only", False), negative_only=params.get("negative_only", False), max_articles=params.get("max_articles", 60), confirmed=True, run_backtest=params.get("run_backtest", False), ) # 记录确认(24h 缓存) if snapshot and snapshot.articles: _mark_confirmed(fingerprint) if snapshot and snapshot.articles: print(f" ✔ 获取 {len(snapshot.articles)} 条记录") except Exception as e: return {"intent": intent, "params": params, "reply": f"❌ 爬取过程出错: {type(e).__name__}: {e}", "output": None} if not snapshot or not snapshot.articles: extra = "" if snapshot is not None and snapshot.stats.get("timed_out"): extra = f"(本次因超时提前返回,耗时 {snapshot.stats.get('elapsed_seconds', 0):.1f}s)" return {"intent": intent, "params": params, "reply": f"📭 未获取到结果。建议:放宽时间窗、添加媒体源、或重试。{extra}", "output": None} # 输出 if intent == "crawl": reply = to_dialog(snapshot) return {"intent": "crawl", "params": params, "reply": reply, "output": snapshot} # crawl_export try: from sentiment_exporter import export as export_sentiment outputs = export_sentiment(snapshot, fmt=params.get("export", "all")) except Exception as e: outputs = {"dialog": to_dialog(snapshot)} # 导出失败不影响对话仍然展示 reply_lines = [f"✅ 已导出 {len(outputs)} 个产物", ""] for k, v in outputs.items(): if k == "dialog": continue reply_lines.append(f" 📦 [{k.upper()}] {v}") if "dialog" in outputs: reply_lines.append("\n--- 对话预览 ---\n" + outputs["dialog"][:2000]) return {"intent": "crawl_export", "params": params, "reply": "\n".join(reply_lines), "output": outputs} if intent == "schedule": try: from crawl_scheduler import get_scheduler scheduler = get_scheduler() from crawl_scheduler import TaskAction crawl_p = params.copy() task = scheduler.create_task( name=f"舆情[{','.join(crawl_p.get('targets') or []) or '默认'}]-{datetime.now().strftime('%H%M%S')}", frequency=crawl_p.get("frequency", "daily"), action=TaskAction(crawl_p.get("action", "crawl_sentiment_export")), sentiment_targets=crawl_p.get("targets") or [], sentiment_source_categories=crawl_p.get("source_categories") or [], sentiment_days=crawl_p.get("days", 7), sentiment_positive_only=crawl_p.get("positive_only", False), sentiment_negative_only=crawl_p.get("negative_only", False), sentiment_export_format=crawl_p.get("export_format", "all"), ) if not scheduler.is_running(): scheduler.start() return {"intent": "schedule", "params": crawl_p, "reply": ( f"✅ 定时任务已创建\n" f" ID: {task.task_id}\n" f" 频率: {task.frequency.value}\n" f" 下次执行: {task.next_run_at}\n" f" 💡 使用 list_scheduled_tasks 可随时查看" ), "output": task} except Exception as e: return {"intent": "schedule", "params": params, "reply": f"❌ 创建定时任务失败: {e}", "output": None} return {"intent": intent, "params": params, "reply": show_help(), "output": None} def _format_targets_reply(data: List[Dict[str, Any]]) -> str: lines = ["🎯 全网舆情目标库", ""] for it in data: lines.append(f" · {it.get('category','')} — {it.get('label','')} ({it.get('count',0)} 个)") lines.append("\n💡 直接对我说:'爬XXX的舆情' 即可触发。") return "\n".join(lines) def _format_sources_reply(data: List[Dict[str, Any]]) -> str: by_cat: Dict[str, List[str]] = {} for item in data: by_cat.setdefault(item.get("category", "未知"), []).append(item.get("name", "")) lines = ["📰 全网舆情媒体源", ""] for cat, names in by_cat.items(): lines.append(f" [{cat}]") for n in names: lines.append(f" · {n}") return "\n".join(lines) # ================================================================ # 3. 帮助 / 使用教程 # ================================================================ def show_help() -> str: return SENTIMENT_HELP SENTIMENT_HELP = """\ 🆕 cn-financial-scraper 全网舆情爬虫 v4.3 — 使用指南 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 我能做:根据您的对话,全网爬取基金公司、上市公司、证券公司、银行、保险、信托等机构的 正面新闻与舆情,并把结果以对话/Word/Excel/CSV/JSON 反馈给您,也可以设置定时任务。 🗞️ 爬取哪些机构的什么信息? · 目标类型 (11 大类): fund_company / listed_company / securities / commercial_bank / insurance / trust_company / private_fund / foreign_institution / futures / wealth_management / leasing_consumer_finance · 信息类型:标题 / 内容简介 / 发布平台 / 发布时间 / 页面连接 — 已自动结构化 · 情感分类:positive 正面 / negative 舆情 / neutral 中性 · 严重等级:低度关注 / 中度舆情 / 高危舆情(负面)/ 低度利好 / 中度利好 / 重大利好(正面) 📰 爬哪些媒体? · authoritative — 财经媒体(财联社/华尔街见闻/第一财经/财新/21世纪/经济观察报/36氪/集思录) · financial_vertical — 财经垂直(财联社/华尔街见闻/一财/财新/21世纪/经济观察报/36氪/集思录) · self_media — 自媒体(微信公众号/微博/今日头条/雪球/东财股吧/知乎/B站财经) · international — 国际媒体(路透/Bloomberg/FT/WSJ/日经中文/港媒) ▶️ 对话示例 — 直接复制即可 · 爬一下贵州茅台最近7天的舆情 · 工银瑞信最近3天的负面新闻,并导出 Excel · 看下华夏基金今天的正面新闻并生成 Word · 每天早上9点爬取银行板块舆情 · 哪些媒体可用?哪些目标? · 新增自定义目标 工银瑞信 · 爬取所有媒体的 贵州茅台 过去24小时舆情,导出 Word/Excel 🔧 完整 API / MCP 工具 · crawl_global_sentiment( 单/多机构, days, 媒体类别, fmt ) · export_sentiment_report( snapshot_id, fmt ) · list_sentiment_targets / list_sentiment_sources · add_sentiment_target( category, name, aliases ) · schedule_crawl_task( frequency, sentiment_targets=..., action=crawl_sentiment_export ) 📂 产物落盘 · 快照 : data/sentiment_snapshots/.json · Word/Excel: data/sentiment_exports/.{docx,xlsx} · 索引 : data/sentiment_snapshots/index.json · 自定义目标: data/sentiment_custom_targets.json ⚠️ 注意事项 · 浏览器自动化 v4.2 已作为兜底,反爬严格时自动启用 · 去重:URL + 标题双维指纹,跨调用生效 · 调度:基于 schedule 库,关闭进程后失效;重启后可从 data/scheduled_tasks.json 自动恢复 · 结果可与 v4.0 内容压缩 / v4.1 海外翻译 / v4.2 浏览器自动化 协同使用 💡 提示 · 我会在您的对话中直接调用工具,并把结果用 Markdown / 文件路径直接呈现 · 关键词「帮助」、「help」、「怎么用」会再次展示这份指南 """ # ================================================================ # 4. CLI 调试 # ================================================================ def _cli(): import argparse parser = argparse.ArgumentParser(description="对话入口 CLI") parser.add_argument("text", nargs="+", help="用户原话") parser.add_argument("--raw", action="store_true", help="仅显示解析结果") args = parser.parse_args() text = " ".join(args.text) parser_obj = SentimentChatParser() parsed = parser_obj.parse(text) if args.raw: print(json.dumps(parsed, ensure_ascii=False, indent=2)) return result = chat_handle(text) print(result["reply"]) if result.get("output") and not isinstance(result["output"], (str, int)): print("\n--- 摘要 ---") if hasattr(result["output"], "to_dict"): out = result["output"] print(f"snapshot_id={out.snapshot_id}, total={len(out.articles)}, pos={out.positive_count()}, neg={out.negative_count()}") if __name__ == "__main__": _cli()