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 条。
174 lines
7.0 KiB
Python
Executable File
174 lines
7.0 KiB
Python
Executable File
#!/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()
|