"""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 os 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: """生成日报并结构化入库(M10:不再生成 HTML/上传)。""" load_dotenv() from scheduler.reporter import generate_report day_str = args.date or _today() print(f"📊 生成日报: {day_str}") report_id = generate_report(day_str, upload=not args.no_upload) if report_id is None: print("⚠️ 无数据或生成失败") return 1 print(f"✅ 日报已入库: report_id={report_id}") return 0 def cmd_report_import(args: argparse.Namespace) -> int: """历史日报 HTML 解析入库(M10)。""" load_dotenv() from report_import.importer import import_history report_dir = args.dir or os.environ.get("REPORT_HISTORY_DIR", "data/reports_history") print(f"📥 导入历史日报: {report_dir} (date={args.date or '全部'} type={args.type or '全部'})") stats = import_history(report_dir, date_str=args.date, report_type=args.type, force=args.force) print(f"✅ 扫描 {stats.scanned} | 新导入 {stats.imported} | 跳过 {stats.skipped} | 失败 {stats.failed}") for err in stats.errors[:20]: print(f" ❌ {err}") return 0 if stats.failed == 0 else 1 # --------------------------------------------------------------------------- # # 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) # ---- report-import ---- p = sub.add_parser("report-import", help="历史日报 HTML 解析入库 (M10)") p.add_argument("--dir", default=None, help="日报目录(默认 REPORT_HISTORY_DIR)") p.add_argument("--date", default=None, help="仅导入指定日期 YYYYMMDD") p.add_argument("--type", default=None, choices=["finance", "intl"], help="仅导入指定类型") p.add_argument("--force", action="store_true", help="已存在也覆盖重导") p.set_defaults(func=cmd_report_import) # ---- 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())