File size: 9,187 Bytes
3291500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
"""
入口:
  --web        网页界面(推荐):浏览器打开,点击即用
  --chat       终端交互模式(默认仍会推钉钉,加 --no-ding 仅本机预览)
  --run-once   单次分析最新一周并推送(加 --no-ding 仅打印,不调钉钉)
  --dry-run / --preview  只生成报告输出到终端(不推送)
  (无参数)     常驻定时任务
"""
import argparse
import logging
import sys
from pathlib import Path

_root = Path(__file__).resolve().parent.parent
if str(_root) not in sys.path:
    sys.path.insert(0, str(_root))

from src.analyzer import run_analysis, run_monthly_analysis
from src.dingtalk import send_markdown
from src.formatter import (
    format_report, format_title,
    format_monthly_report, format_monthly_title,
)
from src.loader import load_config, load_weekly_data, get_available_weeks
from src.parser import parse_query, describe_query, HELP_TEXT

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.StreamHandler(sys.stdout),
        logging.FileHandler(_root / "logs" / "agent.log", encoding="utf-8"),
    ],
)
log = logging.getLogger("ad-agent")


def _configure_stdout_utf8() -> None:
    """避免 Windows 控制台默认 GBK 打印周报里的 emoji 时崩溃。"""
    try:
        if hasattr(sys.stdout, "reconfigure"):
            sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    except Exception:
        pass


def _do_analysis(params: dict, config_path: Path) -> tuple[str, str]:
    """根据解析参数执行分析,返回 (title, markdown_body)。"""
    mode = params["mode"]

    if mode == "month":
        result = run_monthly_analysis(
            config_path=config_path,
            target_year=params["year"],
            target_month=params["month"],
        )
        if result.get("error"):
            return "数据不足", result["error"]
        return format_monthly_title(result), format_monthly_report(result)

    year, week = None, None
    if mode == "week":
        year, week = params["year"], params["week"]
    elif mode == "prev":
        df = load_weekly_data(config_path)
        weeks = get_available_weeks(df)
        if len(weeks) >= 2:
            year, week = weeks[-2]

    result = run_analysis(config_path=config_path, target_year=year, target_week=week)
    return format_title(result), format_report(result)


def run_once(
    config_path: Path | None = None,
    target_year: int | None = None,
    target_week: int | None = None,
    *,
    send_ding: bool = True,
) -> bool:
    config_path = config_path or _root / "config.yaml"
    log.info("开始周度分析...")
    try:
        result = run_analysis(
            config_path=config_path,
            target_year=target_year,
            target_week=target_week,
        )
    except Exception as e:
        log.error(f"分析失败: {e}", exc_info=True)
        return False

    title = format_title(result)
    text = format_report(result)
    if not send_ding:
        print("\n" + "=" * 64)
        print(f"【终端预览 · 未推送钉钉】{title}")
        print("=" * 64 + "\n")
        print(text)
        print("\n" + "=" * 64)
        log.info("已输出到终端(预览模式,未调用钉钉)。")
        return True

    ok, err = send_markdown(title=title, text=text)
    if not ok:
        log.error(f"钉钉发送失败: {err}")
        print(text)
    else:
        log.info(f"已发送: {title}")
    return ok


def chat_loop(config_path: Path, *, send_ding: bool = True) -> None:
    """本地交互模式:读取用户输入 → 分析 → 可选推送到钉钉群。"""
    print("=" * 50)
    print("  DM 广告数据分析 Agent(交互模式)")
    print("=" * 50)
    print()
    if send_ding:
        print("输入分析指令,报告将通过钉钉 Webhook 推送到群。")
    else:
        print("【调试模式】报告只打印在本终端,不会推送到钉钉。")
    print("支持的指令:")
    print("  分析最新一周 / 分析上周")
    print("  分析25年12月 / 分析2025年12月")
    print("  分析W9 / 分析第9周 / 分析2025 W40")
    print("  exit / quit 退出")
    print()

    while True:
        try:
            user_input = input(">>> ").strip()
        except (EOFError, KeyboardInterrupt):
            print("\n再见!")
            break

        if not user_input:
            continue
        if user_input.lower() in ("exit", "quit", "q", "退出"):
            print("再见!")
            break
        if user_input in ("帮助", "help", "?", "?"):
            print(HELP_TEXT)
            continue

        params = parse_query(user_input)
        if params is None:
            print(f"无法识别「{user_input}」,请输入「帮助」查看支持的指令。")
            continue

        desc = describe_query(params)
        print(f"正在分析: {desc} ...")

        try:
            title, body = _do_analysis(params, config_path)
        except Exception as e:
            print(f"分析出错: {e}")
            continue

        print(f"报告已生成: {title}({len(body)} 字符)")
        if not send_ding:
            print("\n--- 报告正文(未推送钉钉)---\n")
            print(body)
            print("\n--- 结束 ---\n")
            continue
        ok, err = send_markdown(title=title, text=body)
        if ok:
            print(f"已推送到钉钉群。")
        else:
            print(f"钉钉推送失败: {err}")
            print("--- 报告内容 ---")
            print(body)
            print("---")
        print()


def main() -> None:
    _configure_stdout_utf8()
    parser = argparse.ArgumentParser(description="广告数据周度分析 Agent")
    parser.add_argument("--web", action="store_true", help="启动网页界面(推荐)")
    parser.add_argument("--chat", action="store_true", help="终端交互模式")
    parser.add_argument("--run-once", action="store_true", help="分析最新一周并推送钉钉(加 --no-ding 则只打印到终端)")
    parser.add_argument("--no-ding", action="store_true", help="不调用钉钉,仅终端输出(配合 --run-once 或 --chat)")
    parser.add_argument("--config", type=Path, default=None, help="config.yaml 路径")
    parser.add_argument("--week", type=str, default=None, help="指定分析周 (YYYY-W)")
    parser.add_argument("--month", type=str, default=None, help="指定分析月 (YYYY-M)")
    parser.add_argument("--dry-run", action="store_true", help="只输出报告到终端,不推送钉钉")
    parser.add_argument("--preview", action="store_true", help="同 --dry-run,便于记")
    args = parser.parse_args()
    config_path = args.config or _root / "config.yaml"

    # ── 网页界面 ──
    if args.web:
        from src.web import start_web
        start_web()
        return

    # ── 终端交互模式 ──
    if args.chat:
        chat_loop(config_path, send_ding=not args.no_ding)
        return

    # ── 解析时间参数 ──
    target_year, target_week, target_month = None, None, None
    if args.week:
        parts = args.week.split("-")
        target_year, target_week = int(parts[0]), int(parts[1])
    if args.month:
        parts = args.month.split("-")
        target_year, target_month = int(parts[0]), int(parts[1])

    # ── Dry-run / Preview ──
    if args.dry_run or args.preview:
        if target_month is not None:
            result = run_monthly_analysis(
                config_path=config_path,
                target_year=target_year,
                target_month=target_month,
            )
            title = format_monthly_title(result)
            text = format_monthly_report(result)
        else:
            result = run_analysis(
                config_path=config_path,
                target_year=target_year,
                target_week=target_week,
            )
            title = format_title(result)
            text = format_report(result)
        print(f"=== {title} ===")
        print(text)
        return

    # ── Run-once ──
    if args.run_once:
        run_once(
            config_path=config_path,
            target_year=target_year,
            target_week=target_week,
            send_ding=not args.no_ding,
        )
        return

    # ── 定时任务 ──
    from apscheduler.schedulers.blocking import BlockingScheduler
    from apscheduler.triggers.cron import CronTrigger

    config = load_config(config_path)
    cron_expr = (config.get("schedule", {}).get("weekly_cron") or "0 9 * * 1").strip()
    parts = cron_expr.split()
    if len(parts) != 5:
        minute, hour, day, month, dow = "0", "9", "*", "*", "1"
    else:
        minute, hour, day, month, dow = parts

    scheduler = BlockingScheduler()
    scheduler.add_job(
        run_once,
        CronTrigger(minute=minute, hour=hour, day=day, month=month, day_of_week=dow),
        args=[config_path],
        id="weekly_report",
    )
    log.info(f"定时任务已启动,cron: {cron_expr}。按 Ctrl+C 退出。")
    scheduler.start()


if __name__ == "__main__":
    main()