Files
FinDataHub/Scripts/bin/install_collector_launchd.py
FinDataHub fb76b10033 feat(cross-platform): 收敛路径与进程语义到 paths / process_utils
Windows 迁移 M0。纯新增两个模块 + 4 个脚本接线,**macOS 行为零漂移**。

背景:仓库此前把 /tmp/findatahub-*.pid、~/Library/Logs、
~/Library/Preferences/...plist 直接写死在十来个文件里;采集管线散落着
start_new_session=True、os.kill(pid,0)、signal.SIGHUP 等 POSIX 专属写法。
这些在 Windows 上分别是:路径不存在、ValueError、探测即杀进程、AttributeError。

- paths.py:temp_dir / pid_file / heartbeat_file / log_root / log_dir /
  app_support_dir / backups_dir / config_dir / user_defaults_plist / env_file
- process_utils.py:detach_kwargs / is_running / terminate_pid /
  available_signals / register_signal_handler / restore_default_and_die /
  utf8_subprocess_env

两个必须记住的坑(已写成测试断言):
1. tempfile.gettempdir() 在 macOS 返回 $TMPDIR(/var/folders/.../T),
   与现网 PID 文件所在的 /tmp **不是同一目录**。paths.temp_dir() 返回
   /tmp 是刻意的,混用会导致新旧进程互相看不见。
2. Windows 上 os.kill(pid, 0) 等价于 TerminateProcess,不是「探测」,
   必须用 OpenProcess。

接线:newsflash_collect(PID/心跳/日志/plist)、daily_summary(PID)、
migrate_date_format(备份目录)、install_collector_launchd(plist + 平台守卫)。

同时修 newsflash_collect.write_heartbeat 的静默失败:Swift 端以心跳
300s 未更新判定卡死并重启,而此处原为 except OSError: pass——重启理由
在日志里毫无痕迹。改为状态翻转才记录(心跳 60s 一次,无脑打日志会刷屏)。

验证:现网采集器 status 正常,5 个路径与旧硬编码逐字一致。
回归:test_paths_process_utils.py 32 条 + 心跳可观测性 5 条。
2026-10-26 20:55:31 +08:00

174 lines
7.0 KiB
Python
Executable File
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.
#!/usr/bin/env python3
"""
install_collector_launchd.py — 将快讯采集器切换为 launchd 批处理模式(P2-7)
替代「7×24 常驻采集循环」,改为:
- RunAtLoad=true:登录后立即执行一次采集(保留「自启」语义)
- StartCalendarInterval:交易时段(工作日 09:00-15:55)每 5 分钟一次,
盘后每 30 分钟、凌晨/周末每小时一次
采集器进程只在执行瞬间存在(跑完即退),空闲时段无任何常驻进程,
这是 P2 层节能的核心收益。launchd 卸载即可无缝切回 App 内常驻模式。
用法:
python3 install_collector_launchd.py \
[--host localhost] [--port 3306] [--user root] [--password xxx] \
[--database news_flash]
未提供密码时从环境变量 FDH_MYSQL_PASSWORD 读取;仍缺失则提示输入。
"""
import argparse
import json
import os
import plistlib
import subprocess
import sys
from pathlib import Path
# 本脚本生成 launchd plist,天然是 macOS 专属工具。
# 显式守卫而非静默失败:在 Windows/Linux 上误跑会写出一个毫无作用、
# 且路径全错的 plist,用户会以为"已经装好调度了"。
if sys.platform != "darwin":
sys.exit(
f"install_collector_launchd.py 仅支持 macOS(当前 {sys.platform})。"
"Windows 请改用任务计划程序(schtasks)或 NSSM,见 "
"Docs/FinDataHub-Windows迁移-执行计划.md。"
)
LAUNCH_AGENTS = Path.home() / "Library/LaunchAgents"
PLIST_PATH = LAUNCH_AGENTS / "com.findatahub.newsflash-collector.plist"
LABEL = "com.findatahub.newsflash-collector"
BIN_DIR = Path(__file__).resolve().parent
# 交易时段(工作日):09:00-15:55 每 5 分钟 → 8 信源时效性核心窗口
def trading_schedule():
entries = []
for weekday in range(1, 6): # 周一(1) ~ 周五(5)
for hour in range(9, 16): # 09:00-15:55
for minute in range(0, 60, 5):
entries.append({"Weekday": weekday, "Hour": hour, "Minute": minute})
return entries
# 盘后(工作日):16:00-23:55 每 30 分钟
def after_hours_schedule():
entries = []
for weekday in range(1, 6):
for hour in range(16, 24):
for minute in (0, 30):
entries.append({"Weekday": weekday, "Hour": hour, "Minute": minute})
return entries
# 凌晨 / 周末:每小时 1 次
def quiet_schedule():
entries = []
for weekday in range(1, 6):
for hour in range(0, 9):
entries.append({"Weekday": weekday, "Hour": hour, "Minute": 0})
for weekday in (6, 7): # 周六(6) / 周日(7)
for hour in range(0, 24):
entries.append({"Weekday": weekday, "Hour": hour, "Minute": 0})
return entries
def read_swift_prefs():
"""从 FinDataHub 的 UserDefaults plist 读取数据库配置(尽力而为)"""
try:
from paths import user_defaults_plist as _defaults_plist
prefs_path = _defaults_plist()
except ImportError:
prefs_path = Path.home() / "Library/Preferences/com.findatahub.app.plist"
# 非 macOS 无 plist:直接返回空,交由环境变量提供配置
if prefs_path is None or not prefs_path.exists():
return {}
try:
with open(prefs_path, "rb") as f:
return plistlib.load(f)
except Exception:
return {}
def main():
parser = argparse.ArgumentParser(description="Install launchd batch-mode collector")
parser.add_argument("--host", default=None)
parser.add_argument("--port", default=None)
parser.add_argument("--user", default=None)
parser.add_argument("--password", default=None)
parser.add_argument("--database", default=None)
args = parser.parse_args()
prefs = read_swift_prefs()
host = args.host or prefs.get("findatahub_db_host") or "localhost"
port = args.port or str(prefs.get("findatahub_db_port") or 3306)
user = args.user or prefs.get("findatahub_db_user") or "root"
database = args.database or prefs.get("findatahub_db_database_news") or "news_flash"
password = args.password or os.environ.get("FDH_MYSQL_PASSWORD") or ""
if not password:
password = input(f"MySQL 密码({user}@{host}:{port}/{database}): ").strip()
# 定位解释器与采集器(优先 kuaixun venv,其次系统 python3)
candidates = [
str(BIN_DIR.parent / "kuaixun-service/.venv/bin/python3"),
str(Path.home() / ".virtualenvs/kuaixun-service/bin/python3"),
"/usr/bin/python3",
]
python = next((p for p in candidates if Path(p).exists()), "python3")
collector = str(BIN_DIR / "newsflash_collect.py")
schedule = trading_schedule() + after_hours_schedule() + quiet_schedule()
plist = {
"Label": LABEL,
"ProgramArguments": [python, collector, "once"],
"RunAtLoad": True, # 保留自启语义:登录即采一次
"StartCalendarInterval": schedule,
"EnvironmentVariables": {
"MYSQL_HOST": host,
"MYSQL_PORT": str(port),
"MYSQL_USER": user,
"MYSQL_PASSWORD": password,
"MYSQL_DATABASE": database,
"MYSQL_DATABASE_NEWS": database,
# RSS 数据源开关默认全开(与 App 内启动一致)
"FDH_RSS_EASTMONEY": "1",
"FDH_RSS_10JQKA": "1",
"FDH_RSS_JIN10": "1",
"FDH_RSS_GELONGHUI": "1",
"FDH_RSS_21JINGJI": "1",
"FDH_RSS_YICAI": "1",
"FDH_RSS_FUTUNN": "1",
"FDH_RSS_HEXUN": "1",
"FDH_RSS_INVESTING": "1",
"FDH_RSS_JRJ": "1",
"FDH_RSS_JIEMIAN": "1",
"FDH_RSS_XUANGUBAO": "1",
"FDH_RSS_XUEQIU": "1",
},
"StandardOutPath": str(Path.home() / "Library/Logs/FinDataHub/newsflash/collector-launchd.out.log"),
"StandardErrorPath": str(Path.home() / "Library/Logs/FinDataHub/newsflash/collector-launchd.err.log"),
}
LAUNCH_AGENTS.mkdir(parents=True, exist_ok=True)
with open(PLIST_PATH, "wb") as f:
plistlib.dump(plist, f)
os.chmod(PLIST_PATH, 0o600) # 含数据库密码,仅所有者可读
# 卸载旧实例再加载(幂等)
subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}", PLIST_PATH],
capture_output=True, check=False)
r = subprocess.run(["launchctl", "bootstrap", f"gui/{os.getuid()}", PLIST_PATH],
capture_output=True, check=False)
if r.returncode != 0:
print(f"launchctl bootstrap 失败: {r.stderr.decode()}", file=sys.stderr)
sys.exit(1)
print(f"✅ 已安装 launchd 批处理采集器:{PLIST_PATH}")
print(f" 解释器: {python}")
print(f" 采集器: {collector}")
print(f" 调度: 交易时段每 5 分钟 / 盘后每 30 分钟 / 凌晨与周末每小时")
print(f" 自启: RunAtLoad=true(登录即采一次)")
print(f" 验证: launchctl print gui/{os.getuid()}/{LABEL}")
print(f" 卸载: python3 {BIN_DIR}/uninstall_collector_launchd.py")
if __name__ == "__main__":
main()