Files

740 lines
32 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.
# -*- 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/<snapshot_id>.json
· Word/Excel: data/sentiment_exports/<snapshot_id>.{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()