Files
news/a_share_cli/main.py
T
2026-07-18 15:51:01 +08:00

963 lines
36 KiB
Python

"""A 股 Deep Research 统一 CLI。
用法:
uv run a-share crawl # M1 抓取
uv run a-share extract --date 20260616 # M2 提取
uv run a-share dedup --date 20260616 # M3 去重
uv run a-share events --date 20260616 # M4 LLM 抽取
uv run a-share embed --date 20260616 # M5 向量化
uv run a-share ingest --date 20260616 # M6 入库
uv run a-share pipeline --once # 全链路
uv run a-share discover https://xxx.com # 分析站点,推荐配置
uv run a-share report # 生成日报
uv run a-share search "宁德时代" # 检索知识库
uv run a-share status # 状态总览
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from collections import Counter
from datetime import date, datetime
from pathlib import Path
from typing import Any
from dotenv import load_dotenv
from loguru import logger
# --------------------------------------------------------------------------- #
# 工具
# --------------------------------------------------------------------------- #
# --------------------------------------------------------------------------- #
# 操作日志
# --------------------------------------------------------------------------- #
_OP_LOG_PATH = Path("logs") / "operations.log"
def _log_operation_start(command: str, detail: str = "") -> None:
"""记录操作开始。"""
_OP_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[{ts}] START | {command:20} | {detail}".rstrip()
with _OP_LOG_PATH.open("a", encoding="utf-8") as f:
f.write(line + "\n")
def _log_operation_end(command: str, success: bool, elapsed: float, detail: str = "") -> None:
"""记录操作结束。"""
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
status = "OK" if success else "FAIL"
detail_str = f" | {detail}" if detail else ""
line = f"[{ts}] END | {command:20} | {status} | {elapsed:.1f}s{detail_str}"
with _OP_LOG_PATH.open("a", encoding="utf-8") as f:
f.write(line + "\n")
def _wrap_cmd(name: str, func, *cmd_args: object) -> int:
"""包装命令函数,自动记录开始/结束日志。"""
from time import perf_counter
detail_parts = []
for a in cmd_args:
if a is not None and a is not False and a != "" and a != 0:
detail_parts.append(str(a)[:80])
detail = " ".join(detail_parts) if detail_parts else ""
_log_operation_start(name, detail)
started = perf_counter()
try:
rc = func()
elapsed = perf_counter() - started
_log_operation_end(name, rc == 0, elapsed)
return rc
except Exception as e:
elapsed = perf_counter() - started
_log_operation_end(name, False, elapsed, f"{type(e).__name__}: {e}")
raise
def _setup_logger(level: str = "WARNING") -> None:
logger.remove()
logger.add(sys.stderr, level=level, format="{level} | {message}")
def _today() -> str:
return date.today().strftime("%Y%m%d")
def _resolve_timeout(step_name: str, hardcoded_default: int) -> int:
"""解析步骤超时(秒),与 scheduler.pipeline 保持一致的优先级。
优先级:
1. TIMEOUT_{STEP_NAME} 环境变量
2. PIPELINE_STEP_TIMEOUT 环境变量 (全局兜底)
3. 硬编码默认值 (本函数参数)
"""
import os
specific_key = f"TIMEOUT_{step_name.upper()}"
if specific_key in os.environ:
return int(os.environ[specific_key])
if "PIPELINE_STEP_TIMEOUT" in os.environ:
return int(os.environ["PIPELINE_STEP_TIMEOUT"])
return hardcoded_default
def _run_module(module: str, extra_args: list[str], timeout: int = 600) -> int:
"""调用现有 scripts/run_*.py 模块。"""
cmd = ["uv", "run", "python", "-m", module, *extra_args]
logger.info("执行: {}", " ".join(cmd))
try:
result = subprocess.run(cmd, timeout=timeout)
return result.returncode
except subprocess.TimeoutExpired:
logger.error("超时 ({}s): {}", timeout, " ".join(cmd))
return 1
# --------------------------------------------------------------------------- #
# 子命令
# --------------------------------------------------------------------------- #
def cmd_crawl(args: argparse.Namespace) -> int:
extra = []
if args.source:
extra.extend(["--source", args.source])
if args.no_save:
extra.append("--no-save")
return _run_module("scripts.run_crawler", extra, timeout=_resolve_timeout("crawler", 600))
def cmd_extract(args: argparse.Namespace) -> int:
extra = ["--date", args.date or _today()]
if args.source:
extra.extend(["--source", args.source])
return _run_module("scripts.run_extractor", extra, timeout=_resolve_timeout("extractor", 300))
def cmd_dedup(args: argparse.Namespace) -> int:
extra = ["--date", args.date or _today()]
if args.reset:
extra.append("--reset")
return _run_module("scripts.run_dedup", extra, timeout=_resolve_timeout("dedup", 120))
def cmd_events(args: argparse.Namespace) -> int:
extra = ["--date", args.date or _today()]
if args.provider:
extra.extend(["--provider", args.provider])
if args.model:
extra.extend(["--model", args.model])
if args.limit:
extra.extend(["--limit", str(args.limit)])
if args.concurrency:
extra.extend(["--concurrency", str(args.concurrency)])
return _run_module("scripts.run_event_extraction", extra, timeout=_resolve_timeout("llm", 900))
def cmd_embed(args: argparse.Namespace) -> int:
extra = ["--date", args.date or _today()]
if args.provider:
extra.extend(["--provider", args.provider])
if args.model:
extra.extend(["--model", args.model])
return _run_module("scripts.run_embedding", extra, timeout=_resolve_timeout("embedding", 300))
def cmd_ingest(args: argparse.Namespace) -> int:
extra = ["--date", args.date or _today()]
if args.recreate:
extra.append("--recreate")
return _run_module("scripts.run_qdrant_ingest", extra, timeout=_resolve_timeout("qdrant", 120))
def cmd_pipeline(args: argparse.Namespace) -> int:
pipeline_timeout = _resolve_timeout("pipeline", 3600)
if args.cninfo_once:
extra = ["--once", "--date", args.date or _today(),
"--steps", "cninfo_crawl,cninfo_extract,cninfo_pdf,dedup,llm,embedding,qdrant"]
return _run_module("scripts.run_scheduler", extra, timeout=pipeline_timeout)
if args.once:
extra = ["--once", "--date", args.date or _today()]
steps = args.steps
if args.report:
steps = (steps + ",report") if steps else "report"
if steps:
extra.extend(["--steps", steps])
return _run_module("scripts.run_scheduler", extra, timeout=pipeline_timeout)
# 守护进程模式:不应通过 CLI 子进程启动(subprocess.run 无法正确管理后台进程)。
# 直接在当前进程启动 APScheduler。
print("🔁 启动守护进程模式 (APScheduler) …")
print(" 提示: 生产环境请使用 systemd 管理,见 docs/systemd.md")
from scripts.run_scheduler import _daemon as _run_daemon
# 构造一个简易的 namespace 给 _daemon
daemon_args = argparse.Namespace()
return _run_daemon(daemon_args)
# --------------------------------------------------------------------------- #
# search — 直接从终端检索知识库
# --------------------------------------------------------------------------- #
def cmd_search(args: argparse.Namespace) -> int:
"""嵌入 query → Qdrant 检索 → 格式化输出。"""
load_dotenv()
_setup_logger("WARNING")
from embedding import make_sync_provider
from vectorstore import SearchFilter, VectorStore, make_qdrant_client
# 嵌入查询
print(f"🔍 检索: {args.query}")
emb = make_sync_provider() # 读取 EMBEDDING_PROVIDER 环境变量
vec = emb.embed_one(args.query)
# 构建过滤
filt = None
has_filter = any([
args.source, args.sentiment, args.min_importance,
args.stock, args.industry,
])
if has_filter:
kwargs: dict[str, Any] = {}
if args.source:
kwargs["source_id"] = args.source
if args.sentiment:
kwargs["sentiment"] = args.sentiment
if args.min_importance:
kwargs["importance_min"] = args.min_importance
if args.stock:
# 股票代码可能带或不带后缀,两者都匹配
code = args.stock.strip().upper()
stock_list = [code] if "." in code else [f"{code}.SZ", f"{code}.SH", f"{code}.BJ"]
kwargs["stock_codes"] = stock_list
if args.industry:
kwargs["industries"] = [args.industry]
filt = SearchFilter(**kwargs)
# 检索
client = make_qdrant_client()
store = VectorStore(client)
hits = store.query(query_vector=vec, top_k=args.top, filter=filt, score_threshold=0.2)
store.close()
emb.close()
if not hits:
print(f"\n未找到与「{args.query}」相关的结果。")
return 0
print(f"\n{len(hits)} 条结果:\n")
for i, h in enumerate(hits, 1):
ev = h.event or {}
sentiment_icon = {"positive": "🟢", "neutral": "⚪", "negative": "🔴"}.get(
ev.get("sentiment"), ""
)
print(f"{i}. {sentiment_icon} {h.title}")
print(f" 来源: {h.source_id} | 相似度: {h.score:.4f} | 时间: {h.publish_time}")
if ev.get("stock_codes"):
print(f" 代码: {','.join(ev['stock_codes'])}")
if ev.get("event_type"):
print(f" 事件: {ev.get('event_type')} (重要度 {ev.get('importance', '-')})")
if ev.get("summary"):
print(f" 摘要: {ev['summary']}")
print(f" {h.url}")
print()
return 0
# --------------------------------------------------------------------------- #
# status — 数据总览
# --------------------------------------------------------------------------- #
def cmd_status(args: argparse.Namespace) -> int: # noqa: ARG001
"""输出各层数据统计。"""
load_dotenv()
today_str = _today()
def _count_jsonl(path: Path) -> int:
if not path.is_file():
return 0
return sum(1 for _ in open(path, encoding="utf-8"))
def _count_dir(pattern: str) -> int:
return len(list(Path().glob(pattern)))
def _count_json(pattern: str) -> int:
return len(list(Path().glob(pattern)))
print(f"📊 A 股 Deep Research 状态 — {today_str}")
print("─" * 50)
# M1 raw
raw_art = 0
for idx in Path("data/raw").glob(f"*/{today_str}/index.jsonl"):
raw_art += _count_jsonl(idx)
print(f" M1 抓取: {raw_art} 篇原始文章")
# M2 processed
proc = _count_json(f"data/processed/*/{today_str}/*.json")
print(f" M2 提取: {proc} 篇正文")
# M3 deduped
deduped = _count_json(f"data/deduped/{today_str}/uniques/*.json")
dup_path = Path(f"data/deduped/{today_str}/duplicates.jsonl")
dups = _count_jsonl(dup_path) if dup_path.is_file() else 0
print(f" M3 去重: {deduped} 篇唯一, {dups} 篇重复")
# M4 events
events = _count_json(f"data/events/{today_str}/*.json")
if events > 0:
sentiments: Counter = Counter()
importances: Counter = Counter()
for fp in Path(f"data/events/{today_str}").glob("*.json"):
try:
obj = json.loads(fp.read_text(encoding="utf-8"))
ev = obj.get("event", {})
sentiments[ev.get("sentiment", "?")] += 1
importances[ev.get("importance", 0)] += 1
except (json.JSONDecodeError, OSError):
pass
high = sum(v for k, v in importances.items() if k >= 4)
print(f" M4 事件: {events} 篇 "
f"(🟢{sentiments.get('positive', 0)} "
f"🔴{sentiments.get('negative', 0)} "
f"⚪{sentiments.get('neutral', 0)}, "
f"重要≥4: {high})")
# M5 embeddings
emb_count = _count_json(f"data/embeddings/{today_str}/*.json")
print(f" M5 向量: {emb_count} 条")
# cninfo 公告
cninfo_raw = 0
for idx in Path("data/raw/cninfo").glob("*/index.jsonl"):
cninfo_raw += _count_jsonl(idx)
if cninfo_raw > 0:
cninfo_proc = _count_json("data/processed/cninfo/*/*.json")
print(f" cninfo: {cninfo_raw} 条公告 (已提取 {cninfo_proc})")
else:
print(" cninfo: 暂无数据")
# M6 Qdrant
try:
from vectorstore import VectorStore, make_qdrant_client
c = make_qdrant_client()
s = VectorStore(c)
total = s.count()
s.close()
print(f" M6 Qdrant: {total} 条向量")
except Exception:
print(" M6 Qdrant: 未连接")
# M7 systemd
try:
r = subprocess.run(
["systemctl", "is-active", "a-share-research"],
capture_output=True, text=True, timeout=5,
)
svc = r.stdout.strip()
icon = "✅" if svc == "active" else "❌"
print(f" M7 调度: {icon} {svc}")
except Exception:
pass
print("─" * 50)
# 高重要度事件
if events > 0:
print("\n🔥 今日重要度 ≥4 的事件:")
high_events: list[dict] = []
for fp in Path(f"data/events/{today_str}").glob("*.json"):
try:
obj = json.loads(fp.read_text(encoding="utf-8"))
ev = obj.get("event", {})
if ev.get("importance", 0) >= 4:
high_events.append({
"title": obj.get("title", ""),
"source": obj.get("source_id", ""),
"sentiment": ev.get("sentiment", ""),
"importance": ev.get("importance", 0),
"summary": ev.get("summary", ""),
})
except (json.JSONDecodeError, OSError):
pass
high_events.sort(key=lambda e: -e["importance"])
for e in high_events[:10]:
icon = {"positive": "🟢", "negative": "🔴", "neutral": "⚪"}.get(e["sentiment"], "")
print(f" {icon} [{e['source']}] {e['title'][:50]} ({e['summary'][:40]})")
return 0
# --------------------------------------------------------------------------- #
# report — 生成每日摘要 HTML 报告并上传
# --------------------------------------------------------------------------- #
def cmd_report(args: argparse.Namespace) -> int:
"""生成 HTML 日报并上传到 Web 服务器。"""
load_dotenv()
from scheduler.reporter import generate_report
day_str = args.date or _today()
print(f"📊 生成日报: {day_str}")
path = generate_report(day_str, upload=not args.no_upload)
if path is None:
print("⚠️ 无数据或生成失败")
return 1
print(f"✅ 日报已保存: {path}")
if not args.no_upload:
from datetime import date as dt_date
today_str = dt_date.today().strftime("%Y%m%d")
print(f"✅ 已上传: http://doorcome.cn/echart/research/{today_str}/")
return 0
# --------------------------------------------------------------------------- #
# discover — 自动分析站点,生成 sources.yaml 配置建议
# --------------------------------------------------------------------------- #
def cmd_discover(args: argparse.Namespace) -> int:
"""抓取首页 → 提取链接 → 按 URL 模式聚类 → 输出 yaml 配置。"""
import re
import urllib.parse
from collections import defaultdict
print(f"🔍 分析站点: {args.url}")
print("⏳ 正在抓取首页(含 JS 渲染)...\n")
# 1. 抓取首页
try:
from crawl4ai import AsyncWebCrawler, BrowserConfig, CacheMode, CrawlerRunConfig
async def _fetch():
bconf = BrowserConfig(headless=True, verbose=False)
rconf = CrawlerRunConfig(cache_mode=CacheMode.BYPASS, page_timeout=30000)
async with AsyncWebCrawler(config=bconf) as crawler:
return await crawler.arun(url=args.url, config=rconf)
import asyncio
result = asyncio.run(_fetch())
except ImportError:
print("❌ 需要 crawl4ai 依赖,请确保已安装")
return 1
except Exception as e:
print(f"❌ 抓取失败: {e}")
return 1
if not result or not getattr(result, "success", False):
print(f"❌ 页面抓取失败: {getattr(result, 'error_message', 'unknown')}")
return 1
html = getattr(result, "html", "") or ""
if not html:
print("❌ 页面无 HTML 内容")
return 1
# 2. 提取所有链接
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
parsed_base = urllib.parse.urlparse(args.url)
base_domain = f"{parsed_base.scheme}://{parsed_base.netloc}"
raw_links: list[tuple[str, str]] = []
for a in soup.find_all("a", href=True):
href = a["href"].strip()
if not href or href.startswith(("javascript:", "#", "mailto:", "tel:")):
continue
absolute = urllib.parse.urljoin(args.url, href).split("#")[0]
anchor = (a.get_text() or "").strip()[:40]
raw_links.append((absolute, anchor))
# 3. 过滤出站内链接,排除导航/静态页
site_links: list[tuple[str, str]] = []
for url, anchor in raw_links:
if base_domain not in url:
continue
# 排除明显的导航/静态页
path = urllib.parse.urlparse(url).path.lower()
if path in ("/", "") or any(path.endswith(ext) for ext in (".css", ".js", ".png", ".jpg", ".ico", ".svg", ".xml", ".pdf")):
continue
site_links.append((url, anchor))
if not site_links:
print("❌ 未发现站内链接(可能需要 JS 渲染或页面结构特殊)")
print(" 可尝试手动打开浏览器开发者工具查看网络请求")
return 1
unique_links = list(dict.fromkeys(site_links)) # 去重保序
# 4. 过滤导航/功能页
_nav_words = (
"about", "download", "feedback", "member", "login", "register", "app",
"calendar", "help", "contact", "privacy", "terms", "service", "buy",
"markets", "codes", "real", "lives",
)
article_links: list[tuple[str, str]] = []
for url, anchor in unique_links:
path_lower = urllib.parse.urlparse(url).path.lower()
parts_lower = [p for p in path_lower.split("/") if p]
# 跳过明显的导航/功能路径(子串匹配)
if any(nav in p for nav in _nav_words for p in parts_lower if len(p) > 2):
continue
article_links.append((url, anchor))
if not article_links:
# 降级:不过滤
article_links = unique_links
# 5. 按 URL 路径模式聚类
clusters: dict[str, list[tuple[str, str]]] = defaultdict(list)
for url, anchor in article_links:
path = urllib.parse.urlparse(url).path.strip("/")
# 把数字/日期/hash 替换为占位符进行聚类
pattern = re.sub(r"/\d{4,}", "/{id}", path)
pattern = re.sub(r"/[a-f0-9]{32,}", "/{hash}", pattern)
pattern = re.sub(r"/\d{4}-\d{2}-\d{2}", "/{date}", pattern)
# 恢复目录结构作为聚类键
parts = pattern.split("/")
# 聚类键:用路径前缀(前两级目录) + 最后一段的模式
key = "/".join(parts[:2]) + "/{...}" if len(parts) >= 2 else (parts[0] if parts else "/")
clusters[key].append((url, anchor))
# 过滤只有 1 条的聚类
clusters = {k: v for k, v in clusters.items() if len(v) >= 2}
if not clusters:
print("⚠️ 未发现明显的文章链接模式(每个 URL 路径都不同)")
print(" 可手动检查页面结构")
return 1
# 6. 按链接数排序,但优先数字 ID 模式(文章特征)
def _score(kv: tuple) -> tuple:
_key, _links = kv
has_num_id = bool(re.search(r"/\d{4,}", _links[0][0])) # URL 含长数字
article_word = any(w in _key.lower() for w in ("article", "news", "detail", "story"))
return (has_num_id or article_word, len(_links))
ranked = sorted(clusters.items(), key=_score, reverse=True)
# 6. 对每个聚类生成正则
site_host = urllib.parse.urlparse(args.url).netloc.replace(".", r"\.")
results: list[dict] = []
for cluster_key, links in ranked:
# 从实际 URL 中提取路径前缀来构建更精确的正则
sample_paths = [urllib.parse.urlparse(u).path for u, _ in links[:5]]
# 找路径公共前缀
common_prefix = _common_path_prefix(sample_paths)
# 推测数字部分
if re.search(r"/\d+", sample_paths[0]):
pattern_re = f"^https?://{site_host}{re.escape(common_prefix)}\\d+"
else:
pattern_re = f"^https?://{site_host}{re.escape(common_prefix)}.*"
# 去掉多余的转义
pattern_re = pattern_re.replace(r"\d+", r"\d+")
results.append({
"key": cluster_key,
"count": len(links),
"regex": pattern_re,
"samples": links[:3],
})
# 7. 输出结果
for i, r in enumerate(results, 1):
stars = "★★★" if i == 1 else ("★★" if i == 2 else "★")
print(f"模式 {chr(64+i)} ({r['count']} 条, 推荐 {stars}):")
print(f" 正则: {r['regex']}")
print(" 样本:")
for url, anchor in r["samples"]:
print(f" {url}" + (f" [{anchor}]" if anchor else ""))
# 8. 输出 yaml 建议
best = results[0]
source_id = parsed_base.netloc.split(".")[0].replace("-", "_")
print()
print("─" * 60)
print("建议 yaml 配置(复制到 configs/sources.yaml):\n")
yaml_snippet = f""" - id: {source_id}
name: {source_id}
enabled: true
homepage: {args.url}
article_url_pattern: '{best['regex']}'
js_render: {str(not getattr(result, 'markdown', '')).lower() if hasattr(result, 'markdown') else 'true'}
wait_for: "css:body"
page_timeout_ms: 30000
max_articles_per_run: 20"""
source_name = args.name or source_id
yaml_snippet = yaml_snippet.replace(f"name: {source_id}", f"name: {source_name}")
# 有额外入口时,追加 extra_homepages 字段
if args.extra_urls:
extra_lines = "\n".join(f" - {u}" for u in args.extra_urls)
yaml_snippet += f"\n extra_homepages:\n{extra_lines}"
print(yaml_snippet)
print("─" * 60)
if args.add:
yaml_path = Path("configs/sources.yaml")
if yaml_path.is_file():
content = yaml_path.read_text(encoding="utf-8")
if f"id: {source_id}" in content:
print(f"\n⚠️ sources.yaml 中已存在 id={source_id},跳过添加")
else:
seq = content.count("\n - id:") + 1
header = f"\n # ---- {seq}. {source_name} ----"
yaml_path.write_text(content.rstrip() + "\n" + header + "\n" + yaml_snippet + "\n", encoding="utf-8")
print(f"\n✅ 已追加到 configs/sources.yaml (第 {seq} 个源)")
else:
print("\n⚠️ configs/sources.yaml 不存在,无法自动添加")
print(f"\n验证: uv run a-share crawl --source {source_id}")
return 0
# --------------------------------------------------------------------------- #
# add-entry — 为已有源追加 extra_homepages
# --------------------------------------------------------------------------- #
def cmd_add_entry(args: argparse.Namespace) -> int:
"""给已有源追加 extra_homepages 入口(文本模式,保留 yaml 原有格式)。"""
import re
yaml_path = Path("configs/sources.yaml")
if not yaml_path.is_file():
print("❌ configs/sources.yaml 不存在")
return 1
content = yaml_path.read_text(encoding="utf-8")
if f"id: {args.source}" not in content:
print(f"❌ 未找到源 id={args.source}")
return 1
# 提取该 source 块中已有的 extra_homepages URL
lines = content.splitlines()
in_block = False
existing: set[str] = set()
for line in lines:
if f"id: {args.source}" in line and line.strip().startswith("- id:"):
in_block = True
continue
if in_block and (line.strip().startswith("- id:") or line.strip().startswith("settings:")):
break
m = re.match(r"\s+- (https?://\S+)", line)
if m:
existing.add(m.group(1))
added = []
seen_this_run: set[str] = set()
for url in args.urls:
if url in existing or url in seen_this_run:
print(f"⏭ 跳过(已存在): {url}")
else:
seen_this_run.add(url)
added.append(url)
print(f"✅ 已添加: {url}")
if not added:
print("无新增入口")
return 0
# 在 source 块内插入/追加
has_extra_header = any("extra_homepages:" in lines[i] for i in range(len(lines)) if in_block_from(lines, i, args.source))
if has_extra_header:
# 找到块内最后一个 extra URL 行,在其后追加
insert_at = -1
for i in range(len(lines)):
if in_block_from(lines, i, args.source) and re.match(r"\s+- https?://", lines[i]) and "extra_homepages:" in "\n".join(lines[max(0, i - 2):i + 1]):
insert_at = i
if insert_at > 0:
indent = " "
for u in added:
lines.insert(insert_at + 1, f"{indent}- {u}")
insert_at += 1
else:
# 在 max_articles_per_run 行后插入 extra_homepages
for i in range(len(lines)):
if in_block_from(lines, i, args.source) and "max_articles_per_run:" in lines[i]:
indent = " "
new_block = [f"{indent}extra_homepages:"]
for u in added:
new_block.append(f"{indent} - {u}")
for j, nl in enumerate(new_block):
lines.insert(i + 1 + j, nl)
break
yaml_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"\n✅ 已写入 configs/sources.yaml ({len(added)} 个新入口)")
return 0
def in_block_from(lines: list[str], idx: int, source_id: str) -> bool:
"""检查行 idx 是否在 source_id 的配置块内。"""
for i in range(idx, -1, -1):
if lines[i].strip().startswith(f"- id: {source_id}"):
return True
if lines[i].strip().startswith("- id:") or lines[i].strip().startswith("settings:"):
return False
return False
# --------------------------------------------------------------------------- #
# watchlist 管理
# --------------------------------------------------------------------------- #
_WATCHLIST_PATH = Path("configs/watchlist.yaml")
def _load_watchlist() -> list[dict]:
import yaml
try:
with _WATCHLIST_PATH.open(encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return list(data.get("watchlist") or [])
except Exception:
return []
def _save_watchlist(items: list[dict]) -> None:
import yaml
_WATCHLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
with _WATCHLIST_PATH.open("w", encoding="utf-8") as f:
yaml.dump({"watchlist": items}, f, allow_unicode=True, default_flow_style=False, sort_keys=False)
def cmd_watchlist_add(args: argparse.Namespace) -> int:
items = _load_watchlist()
code = args.code.strip()
for it in items:
if it.get("code") == code:
it["name"] = args.name
if args.note:
it["note"] = args.note
_save_watchlist(items)
print(f"✅ 已更新: {code} {args.name}")
return 0
items.append({"code": code, "name": args.name, "note": args.note or ""})
_save_watchlist(items)
print(f"✅ 已添加: {code} {args.name}")
return 0
def cmd_watchlist_remove(args: argparse.Namespace) -> int:
items = _load_watchlist()
code = args.code.strip()
new_items = [it for it in items if it.get("code") != code]
if len(new_items) == len(items):
print(f"⚠️ 未找到: {code}")
return 1
_save_watchlist(new_items)
print(f"✅ 已移除: {code}")
return 0
def cmd_watchlist_list(args: argparse.Namespace) -> int: # noqa: ARG001
items = _load_watchlist()
if not items:
print("📋 关注列表为空")
print("添加: uv run a-share watchlist add 000001 平安银行")
return 0
print(f"📋 关注列表 ({len(items)} 家):")
for it in items:
note = f" — {it.get('note','')}" if it.get("note") else ""
print(f" {it['code']} {it['name']}{note}")
return 0
def cmd_stock_report(args: argparse.Namespace) -> int:
"""生成所有关注公司个股日报。"""
from scheduler.stock_reporter import generate_all_stock_reports
n = generate_all_stock_reports(upload=not args.no_upload)
print(f"✅ 个股报告完成: {n} 家")
return 0 if n > 0 else 1
def cmd_cninfo(args: argparse.Namespace) -> int:
"""cninfo watchlist 抓取(公告+调研+互动易,API 优先)。"""
from crawler.cninfo import crawl_watchlist, enrich_articles_with_pdf
if args.enrich_pdf:
n = enrich_articles_with_pdf(day_str=None, limit=args.pdf_limit)
print(f"✅ PDF 正文提取完成: {n} 篇")
return 0 if n > 0 else 1
# 默认: watchlist 全类型抓取(公告+调研+互动易)
results = crawl_watchlist(save=not args.no_save)
if not results:
print("⚠️ cninfo 抓取: 无数据")
return 1
# 按类型统计
ann = sum(1 for it in results if getattr(it, "item_type", "") == "announcement")
res = sum(1 for it in results if getattr(it, "item_type", "") == "research")
irm = sum(1 for it in results if getattr(it, "item_type", "") == "irm")
print(f"✅ cninfo 抓取完成: 公告 {ann} / 调研 {res} / 互动易 {irm} (共 {len(results)} 条)")
return 0
def _common_path_prefix(paths: list[str]) -> str:
"""找路径列表的公共前缀(逐字符)。"""
if not paths:
return "/"
shortest = min(paths, key=len)
for i, ch in enumerate(shortest):
if any(p[i:i+1] != ch for p in paths):
prefix = shortest[:i]
# 截断到最后一个 /
last_slash = prefix.rfind("/")
return prefix[:last_slash + 1] if last_slash >= 0 else "/"
return shortest
# --------------------------------------------------------------------------- #
# 主入口
# --------------------------------------------------------------------------- #
def main() -> int:
load_dotenv()
parser = argparse.ArgumentParser(
prog="a-share",
description="A 股 Deep Research 统一 CLI",
)
sub = parser.add_subparsers(dest="command", help="子命令")
# ---- crawl ----
p = sub.add_parser("crawl", help="M1 新闻抓取")
p.add_argument("--source", default=None)
p.add_argument("--no-save", action="store_true")
p.set_defaults(func=cmd_crawl)
# ---- extract ----
p = sub.add_parser("extract", help="M2 正文提取")
p.add_argument("--date", default=None)
p.add_argument("--source", default=None)
p.set_defaults(func=cmd_extract)
# ---- dedup ----
p = sub.add_parser("dedup", help="M3 三层去重")
p.add_argument("--date", default=None)
p.add_argument("--reset", action="store_true")
p.set_defaults(func=cmd_dedup)
# ---- events ----
p = sub.add_parser("events", help="M4 LLM 事件抽取")
p.add_argument("--date", default=None)
p.add_argument("--provider", default=None)
p.add_argument("--model", default=None)
p.add_argument("--limit", type=int, default=0)
p.add_argument("--concurrency", type=int, default=0)
p.set_defaults(func=cmd_events)
# ---- embed ----
p = sub.add_parser("embed", help="M5 向量化")
p.add_argument("--date", default=None)
p.add_argument("--provider", default=None)
p.add_argument("--model", default=None)
p.set_defaults(func=cmd_embed)
# ---- ingest ----
p = sub.add_parser("ingest", help="M6 Qdrant 入库")
p.add_argument("--date", default=None)
p.add_argument("--recreate", action="store_true")
p.set_defaults(func=cmd_ingest)
# ---- pipeline ----
p = sub.add_parser("pipeline", help="M7 全链路 / 定时守护")
p.add_argument("--once", action="store_true", help="立即执行一次")
p.add_argument("--date", default=None)
p.add_argument("--steps", default=None, help="指定步骤(crawler,extractor,...,report)")
p.add_argument("--report", action="store_true", help="全链路末尾生成日报")
p.add_argument("--cninfo-once", action="store_true", help="cninfo watchlist 全链路(公告+调研+IRM)")
p.set_defaults(func=cmd_pipeline)
# ---- search ----
p = sub.add_parser("search", help="检索知识库")
p.add_argument("query", help="自然语言查询")
p.add_argument("--top", type=int, default=10)
p.add_argument("--source", default=None)
p.add_argument("--sentiment", default=None, choices=["positive", "negative", "neutral"])
p.add_argument("--min-importance", type=int, default=None, dest="min_importance")
p.add_argument("--stock", default=None, help="6 位股票代码")
p.add_argument("--industry", default=None, help="行业名")
p.set_defaults(func=cmd_search)
# ---- add-entry ----
p = sub.add_parser("add-entry", help="为已有源追加 extra_homepages 入口")
p.add_argument("source", help="已有源 id(如 wallstreetcn)")
p.add_argument("urls", nargs="+", help="额外入口 URL(可多个)")
p.set_defaults(func=cmd_add_entry)
# ---- discover ----
p = sub.add_parser("discover", help="分析站点首页,推荐 sources.yaml 配置")
p.add_argument("url", help="站点首页 URL(主入口)")
p.add_argument("--extra", action="append", default=[], dest="extra_urls",
help="额外入口 URL,可重复使用(同站多频道)")
p.add_argument("--add", action="store_true", help="自动追加到 configs/sources.yaml")
p.add_argument("--name", default=None, help="站点中文名(如 华尔街见闻)")
p.set_defaults(func=cmd_discover)
# ---- report ----
p = sub.add_parser("report", help="生成每日 HTML 报告并上传")
p.add_argument("--date", default=None)
p.add_argument("--no-upload", action="store_true", help="仅生成不上传")
p.set_defaults(func=cmd_report)
# ---- watchlist ----
p = sub.add_parser("watchlist", help="管理 cninfo 公告关注列表")
sp = p.add_subparsers(dest="wl_cmd")
pa = sp.add_parser("add", help="添加关注公司")
pa.add_argument("code", help="6 位股票代码")
pa.add_argument("name", help="公司简称")
pa.add_argument("--note", default="", help="备注")
pa.set_defaults(func=cmd_watchlist_add)
pr = sp.add_parser("remove", help="移除关注公司")
pr.add_argument("code", help="6 位股票代码")
pr.set_defaults(func=cmd_watchlist_remove)
pl = sp.add_parser("list", help="查看关注列表")
pl.set_defaults(func=cmd_watchlist_list)
# ---- stock-report ----
p = sub.add_parser("stock-report", help="生成关注列表个股日报")
p.add_argument("--no-upload", action="store_true", help="仅生成不上传")
p.set_defaults(func=cmd_stock_report)
# ---- cninfo ----
p = sub.add_parser("cninfo", help="cninfo watchlist 抓取(公告+调研+互动易, API 优先)")
p.add_argument("--no-save", action="store_true")
p.add_argument("--enrich-pdf", action="store_true", help="下载 PDF 补充正文")
p.add_argument("--pdf-limit", type=int, default=50, help="PDF 最多处理 N 篇")
p.set_defaults(func=cmd_cninfo)
# ---- status ----
p = sub.add_parser("status", help="数据总览")
p.set_defaults(func=cmd_status)
args = parser.parse_args()
if args.command is None:
parser.print_help()
return 0
# 记录操作日志
cmd_name = args.command
# 提取关键参数作为日志详情
detail_parts = []
for attr in ["source", "date", "query", "days", "stock", "industry",
"sentiment", "once", "cninfo_once", "report"]:
val = getattr(args, attr, None)
if val is not None and val is not False and val != "" and val != 0:
detail_parts.append(f"{attr}={val}")
detail = " ".join(detail_parts) if detail_parts else ""
from time import perf_counter
_log_operation_start(cmd_name, detail)
started = perf_counter()
rc = args.func(args)
elapsed = perf_counter() - started
_log_operation_end(cmd_name, rc == 0, elapsed)
return rc
if __name__ == "__main__":
raise SystemExit(main())