mpdt 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mpdt/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """mpdt — 公众号运营数据自动化工具(C1 / mp-data-tool)
2
+
3
+ 错误语义(FR6/T1 三态分离):
4
+ 0 成功(含"该日期范围无数据")
5
+ 2 配置问题(未 init / 配置缺失)
6
+ 3 凭据问题(token 刷新失败)
7
+ 4 上游接口异常(结构变更,含原始返回片段)
8
+ """
9
+ __version__ = '0.2.0'
mpdt/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """python3 -m mpdt <子命令> 入口(等价于 python3 -c 'from mpdt.cli import main; ...')。"""
2
+ import sys
3
+
4
+ from .cli import main
5
+
6
+ if __name__ == '__main__':
7
+ sys.exit(main())
mpdt/cli.py ADDED
@@ -0,0 +1,624 @@
1
+ #!/usr/bin/env python3
2
+ """mpdt CLI(FR1/FR3/FR4/FR5/FR6)。子命令:init / fetch / detail / report / export / metrics / doctor / status。
3
+ 退出码:0 成功(含空数据)|2 配置|3 凭据|4 上游异常|130 用户中断。"""
4
+ import argparse
5
+ import datetime as dt
6
+ import json
7
+ import os
8
+ import sqlite3
9
+ import stat as stat_mod
10
+ import sys
11
+ import urllib.error
12
+ import urllib.request
13
+
14
+ from . import __version__, config, store, report, metrics as metrics_mod
15
+ from .errors import MpdtError, UpstreamError
16
+
17
+
18
+ def today_shanghai() -> dt.date:
19
+ return dt.datetime.now(dt.timezone(dt.timedelta(hours=8))).date()
20
+
21
+
22
+ # ---- 阶段 6 打磨:--json / 静默 TTY 检测 / Ctrl-C(阶段 4 打磨项)----
23
+
24
+ class _R:
25
+ """收集单命令的结构化结果:print(人读)+ data(--json 时输出)。"""
26
+ def __init__(self, quiet=False):
27
+ self.quiet = quiet # 非 TTY(管道/重定向/cron)默认静默过程输出
28
+ self.data = None # dict → --json 时以 JSON 打印
29
+ self.meta = {} # 机器可读元信息(行数/路径等,与人读文本分离)
30
+
31
+ def say(self, msg):
32
+ """过程输出(进度条性质)。--json 时一律不出(污染管道);非 TTY 且未强制 → 静默。"""
33
+ if not self.quiet and not JSON_FLAG[0]:
34
+ print(msg)
35
+
36
+ def progress(self, i, n, msg):
37
+ """进度行:[第 N/总] msg(TTY 人读;--json/管道静默)。"""
38
+ self.say(f' [{i}/{n}] {msg}')
39
+
40
+ def out(self, msg, **meta):
41
+ """结果输出(人读版)。--json 时不出,改为 JSON。meta 同步进 self.meta。"""
42
+ self.meta.update(meta)
43
+ if not JSON_FLAG[0]:
44
+ print(msg)
45
+
46
+ def result(self, ok_msg, data, **meta):
47
+ """统一收尾:data 为本命令的结构化结果(dict/list)。"""
48
+ self.data = data
49
+ self.meta.update(meta)
50
+ if not JSON_FLAG[0]:
51
+ print(ok_msg)
52
+ return 0
53
+
54
+ def payload(self):
55
+ """--json 时的输出体:{ok, cmd, data, meta}——T1 三态在 main() 里以 exit_code 区分,
56
+ timeout/unknown 不与成功混在同一个 JSON 语义里(写后读回型用户拿 exit code 判定)。"""
57
+ return {'ok': True, 'cmd': CMD_NAME[0], 'data': self.data, 'meta': self.meta}
58
+
59
+
60
+ JSON_FLAG = [False] # main() 解析后置位;模块级因 argparse 在 fn 内之后执行
61
+ CMD_NAME = ['']
62
+
63
+ # 快照数据源(与 store.SCHEMA 注释、metrics.SRC_* 一致):export --source 合法值
64
+ SOURCES = ['publish_list', 'datacube_article', 'article_detail']
65
+
66
+
67
+ def cmd_init(args):
68
+ import os
69
+ existed = os.path.exists(config.CONFIG_PATH)
70
+ r = RES[0]
71
+ if existed:
72
+ r.out(f'配置已存在:{config.CONFIG_PATH}(如需重置请手动删除)')
73
+ cfg = dict(config.DEFAULTS)
74
+ cfg['db_path'] = os.path.expanduser(args.db) if args.db else cfg['db_path']
75
+ config.save(cfg)
76
+ r.out(f'已生成 {config.CONFIG_PATH}(权限 600)。')
77
+ r.say('下一步:确保本机 Chrome 以 --remote-debugging-port=9222 启动且已登录 '
78
+ 'mp.weixin.qq.com,然后运行 mpdt fetch。')
79
+ return r.result('init 完成', {'config_path': config.CONFIG_PATH,
80
+ 'db_path': cfg['db_path'], 'existed': existed})
81
+
82
+
83
+ def cmd_fetch(args):
84
+ r = RES[0]
85
+ cfg = config.load()
86
+ from .session import Session
87
+ from .fetchers import publish_list, datacube
88
+ s = Session(cfg)
89
+ con = store.connect(cfg['db_path'])
90
+ today = today_shanghai()
91
+ end = today - dt.timedelta(days=1) # T+1:昨天是最新可得日
92
+ begin = today - dt.timedelta(days=args.backfill) if args.backfill else end
93
+ if args.from_date:
94
+ begin = dt.date.fromisoformat(args.from_date)
95
+ if args.to_date:
96
+ end = dt.date.fromisoformat(args.to_date)
97
+ stat_date = today.isoformat() # 快照行=拉取时点,refdate 才是文章口径日
98
+ n_total = 0
99
+ r.say(f'拉取发表列表(count={args.count})…')
100
+ pl = publish_list.fetch(s, count=args.count)
101
+ for i, a in enumerate(pl['articles'], 1):
102
+ r.progress(i, len(pl['articles']), f'入库 {a["title"][:24]}')
103
+ store.upsert(con, 'publish_list', stat_date, a['appmsgid'], a['itemidx'],
104
+ a['title'], {k: a[k] for k in
105
+ ('read_num', 'share_num', 'like_num', 'old_like_num', 'comment_num')})
106
+ n_total += len(pl['articles'])
107
+ store.log_run(con, 'fetch:publish_list', True, f"{len(pl['articles'])} articles")
108
+ r.say(f'拉取 datacube({begin} ~ {end},T+1 口径)…')
109
+ dc = datacube.fetch(s, begin.strftime('%Y%m%d'), end.strftime('%Y%m%d'))
110
+ for i, r2 in enumerate(dc, 1):
111
+ r.progress(i, len(dc), f'入库 {r2.get("title", "")[:24]}')
112
+ store.upsert(con, 'datacube_article', stat_date, r2.get('appmsgid'), r2.get('itemidx', 1),
113
+ r2.get('title', ''), {k: r2.get(k) for k in
114
+ ('refdate', 'finish_read_rate', 'read_rate', 'total_pv',
115
+ 'total_read_finish', 'total_share', 'send_succ')})
116
+ n_total += len(dc)
117
+ store.log_run(con, 'fetch:datacube', True, f"{len(dc)} rows {begin}~{end}")
118
+ con.commit()
119
+ data = {'publish_list': len(pl['articles']), 'datacube_rows': len(dc),
120
+ 'begin': begin.isoformat(), 'end': end.isoformat(), 'db_path': cfg['db_path']}
121
+ # T+1 提示(AC4):空数据不是错误
122
+ if n_total == 0:
123
+ return r.result(f'该日期范围无数据({begin}~{end};当天数据 T+1 后可得)。', data)
124
+ r.say('下一步:mpdt report 生成本周周报(Markdown+HTML)。')
125
+ return r.result(
126
+ f'快照完成:publish_list {len(pl["articles"])} 篇 + datacube {len(dc)} 行 → {cfg["db_path"]}',
127
+ data)
128
+
129
+
130
+ RES = [None] # main() 里按 TTY 情况构造
131
+
132
+
133
+ def cmd_detail(args):
134
+ """单篇渠道页(FR3②):完读率/时长/互动 + 逐渠道人数构成(含推荐/搜一搜)。"""
135
+ r = RES[0]
136
+ cfg = config.load()
137
+ from .session import Session
138
+ from .fetchers import publish_list as pl_mod, article_detail as ad_mod
139
+ s = Session(cfg)
140
+ con = store.connect(cfg['db_path'])
141
+ today = today_shanghai()
142
+ stat_date = today.isoformat()
143
+ targets = []
144
+ if args.msgid:
145
+ if not args.publish_date:
146
+ if JSON_FLAG[0]:
147
+ print(json.dumps({'ok': False, 'cmd': 'detail', 'error': {
148
+ 'exit_code': 2, 'message': '--msgid 需同时给 --publish-date YYYY-MM-DD'}},
149
+ ensure_ascii=False))
150
+ else:
151
+ print('[mpdt] 错误(退出码 2):--msgid 需同时给 --publish-date YYYY-MM-DD',
152
+ file=sys.stderr)
153
+ return 2
154
+ targets.append((args.msgid, args.item_idx, args.publish_date))
155
+ else:
156
+ days = args.recent or 30
157
+ pl = pl_mod.fetch(s, count=args.count)
158
+ cutoff = (today - dt.timedelta(days=days - 1)).isoformat()
159
+ seen = set()
160
+ for a in pl['articles']:
161
+ if not a.get('publish_date') or a['publish_date'] < cutoff:
162
+ continue
163
+ key = (str(a['appmsgid']), int(a['itemidx']))
164
+ if key in seen:
165
+ continue
166
+ seen.add(key)
167
+ targets.append((key[0], key[1], a['publish_date']))
168
+ r.say(f'最近 {days} 天发表记录命中 {len(targets)} 篇,开始抓单篇渠道页(≥1s/篇)…')
169
+ ok, fail = 0, 0
170
+ ok_items = []
171
+ n_targets = len(targets)
172
+ for t_i, (mid, idx, pub) in enumerate(targets, 1):
173
+ r.progress(t_i, n_targets, f'{mid}_{idx}(≥1s/篇限速)')
174
+ try:
175
+ d = ad_mod.fetch(s, mid, idx, pub)
176
+ except MpdtError as e:
177
+ fail += 1
178
+ r.say(f' ! {mid}_{idx} 跳过:{e}')
179
+ print(f' ! {mid}_{idx} 跳过:{e}', file=sys.stderr)
180
+ store.log_run(con, f'detail:{mid}_{idx}', False, str(e)[:200])
181
+ continue
182
+ metrics = dict(d['metrics'])
183
+ metrics.update({
184
+ 'publish_date': d['publish_date'],
185
+ 'window_days': d['window_days'],
186
+ 'total_read_user': sum(rr['read_user'] for rr in d['channels']
187
+ if rr['scene'] == ad_mod.ALL_SCENE),
188
+ 'channel_breakdown': d['channel_breakdown'],
189
+ })
190
+ store.upsert(con, 'article_detail', stat_date, d['appmsgid'], d['itemidx'],
191
+ d['title'], metrics)
192
+ ok += 1
193
+ top = d['channel_breakdown'][0] if d['channel_breakdown'] else None
194
+ rec = next((rr for rr in d['channel_breakdown']
195
+ if rr['scene_label'] == '推荐'), None)
196
+ ok_items.append({'appmsgid': d['appmsgid'], 'itemidx': d['itemidx'],
197
+ 'title': d['title'], 'publish_date': d['publish_date'],
198
+ 'read_uv': metrics.get('read_uv'),
199
+ 'finished_read_pv_ratio': metrics.get('finished_read_pv_ratio'),
200
+ 'top_channel': (top or {}).get('scene_label'),
201
+ 'top_channel_pct': (top or {}).get('read_pct'),
202
+ 'recommend_pct': (rec or {}).get('read_pct')})
203
+ r.say(' ✓ %s_%d 阅读%s 完读率%s 推荐%s%% 首渠道=%s(%s%%)' % (
204
+ d['appmsgid'], d['itemidx'], metrics.get('read_uv'),
205
+ ('%.1f%%' % (100 * metrics['finished_read_pv_ratio'])
206
+ if isinstance(metrics.get('finished_read_pv_ratio'), (int, float)) else 'NA'),
207
+ (rec or {}).get('read_pct', 'NA'),
208
+ (top or {}).get('scene_label', 'NA'), (top or {}).get('read_pct', 'NA')))
209
+ store.log_run(con, 'fetch:article_detail', ok > 0 or not targets,
210
+ f'ok={ok} fail={fail} targets={len(targets)}')
211
+ con.commit()
212
+ if targets and ok == 0:
213
+ raise UpstreamError('单篇渠道页全部抓取失败(%d 篇)' % fail)
214
+ return r.result(f'单篇渠道页快照完成:{ok} 篇入库(失败 {fail})→ {cfg["db_path"]}',
215
+ {'ok': ok, 'fail': fail, 'targets': len(targets), 'items': ok_items,
216
+ 'db_path': cfg['db_path']})
217
+
218
+
219
+ def cmd_report(args):
220
+ r = RES[0]
221
+ cfg = config.load()
222
+ con = store.connect(cfg['db_path'])
223
+ week = args.week or f'{today_shanghai().isocalendar().year}-W{today_shanghai().isocalendar().week:02d}'
224
+ out = report.generate(con, week, args.out or 'mpdt-reports')
225
+ return r.result(f"周报已生成:{out['md']}(数据行 {out['rows']},跨源对照 {out['cross_rows']} 行)",
226
+ {'md': out['md'], 'html': out['html'], 'rows': out['rows'],
227
+ 'cross_rows': out['cross_rows'],
228
+ 'concepts_compared': out.get('concepts_compared', []),
229
+ 'concepts_empty': out.get('concepts_empty', []),
230
+ 'week': week})
231
+
232
+
233
+ # ---- doctor / status:一站式体检与状态查询(UX 打磨,全部只读) ----
234
+
235
+ def _db_readonly_stats(db_path):
236
+ """以只读方式打开 db,返回 {exists, rows_by_source, latest_snapshot, db_bytes, error}。"""
237
+ out = {'exists': os.path.exists(db_path), 'rows_by_source': {},
238
+ 'latest_snapshot': None, 'db_bytes': None, 'error': None}
239
+ if not out['exists']:
240
+ return out
241
+ out['db_bytes'] = os.path.getsize(db_path)
242
+ try:
243
+ con = sqlite3.connect(f'file:{db_path}?mode=ro', uri=True, timeout=2)
244
+ try:
245
+ for src in ('publish_list', 'datacube_article', 'article_detail'):
246
+ out['rows_by_source'][src] = con.execute(
247
+ 'SELECT COUNT(*) FROM snapshots WHERE source=?', (src,)).fetchone()[0]
248
+ row = con.execute('SELECT MAX(stat_date) FROM snapshots').fetchone()
249
+ out['latest_snapshot'] = row[0] if row else None
250
+ finally:
251
+ con.close()
252
+ except sqlite3.Error as e:
253
+ out['error'] = str(e)
254
+ return out
255
+
256
+
257
+ def cmd_doctor(args):
258
+ """一键体检(只读):配置 / 权限 / Chrome CDP / db / 快照新鲜度逐项检查。
259
+
260
+ 人读输出 ✓/✗/⚠ 清单;--json 输出同结构 data;始终 exit 0(诊断结果不是命令成败)。
261
+ """
262
+ r = RES[0]
263
+ items = []
264
+
265
+ def add(name, status, detail, data=None):
266
+ items.append({'name': name, 'status': status, 'detail': detail,
267
+ **(data or {})})
268
+
269
+ # ① 配置文件存在
270
+ cfg_exists = os.path.exists(config.CONFIG_PATH)
271
+ add('配置文件', 'ok' if cfg_exists else 'fail',
272
+ config.CONFIG_PATH if cfg_exists else f'未找到 {config.CONFIG_PATH} → 运行 mpdt init')
273
+
274
+ # ② 配置可解析(存在时)
275
+ cfg = None
276
+ if cfg_exists:
277
+ try:
278
+ cfg = config.load()
279
+ add('配置解析', 'ok', 'JSON 可读,默认项已合并')
280
+ except Exception as e:
281
+ add('配置解析', 'fail', f'损坏({e})→ 删除后重跑 mpdt init')
282
+
283
+ # ③ 配置权限 600
284
+ if cfg_exists:
285
+ mode = stat_mod.S_IMODE(os.stat(config.CONFIG_PATH).st_mode)
286
+ perms = oct(mode)[2:]
287
+ if mode == 0o600:
288
+ add('配置权限', 'ok', '600(仅本用户可读写)')
289
+ else:
290
+ add('配置权限', 'warn', f'{perms}(期望 600)→ chmod 600 ' + config.CONFIG_PATH)
291
+
292
+ # ④ Chrome CDP 可达性(5s 超时,只探不发命令)
293
+ cdp_url = (cfg or {}).get('chrome_cdp', config.DEFAULTS['chrome_cdp'])
294
+ cdp_detail, mp_tab = f'{cdp_url} 不可达(先以 --remote-debugging-port=9222 启动 Chrome)', False
295
+ try:
296
+ tabs = json.load(urllib.request.urlopen(cdp_url + '/json', timeout=5))
297
+ mp_tab = any(t.get('type') == 'page' and 'mp.weixin' in t.get('url', '')
298
+ for t in tabs if isinstance(t, dict))
299
+ if mp_tab:
300
+ cdp_detail = f'{cdp_url} 可达,且存在已登录态的 mp.weixin 标签页'
301
+ else:
302
+ cdp_detail = f'{cdp_url} 可达,但无 mp.weixin 标签页 → 在该 Chrome 里打开并登录 mp.weixin.qq.com'
303
+ add('Chrome CDP', 'ok' if mp_tab else 'warn', cdp_detail,
304
+ {'cdp_url': cdp_url, 'mp_tab': mp_tab})
305
+ except Exception as e:
306
+ add('Chrome CDP', 'fail', cdp_detail, {'cdp_url': cdp_url, 'error': str(e)[:120]})
307
+
308
+ # ⑤ token(cfg 里有则只报存在性,绝不回显)
309
+ has_token = bool((cfg or {}).get('token'))
310
+ add('token', 'ok' if has_token else 'warn',
311
+ '已缓存(隐藏显示,过期时 fetch 会经 CDP 自动刷新)' if has_token
312
+ else '未缓存(首次 fetch 会经 Chrome 自动获取,不影响使用)')
313
+
314
+ # ⑥ db 文件 / 各源行数 / 最近快照日
315
+ db_path = (cfg or {}).get('db_path', config.DEFAULTS['db_path'])
316
+ st = _db_readonly_stats(db_path)
317
+ if not st['exists']:
318
+ add('数据库文件', 'warn', f'{db_path} 尚不存在(首次 fetch 时自动创建)')
319
+ elif st['error']:
320
+ add('数据库文件', 'warn', f'{db_path} 存在但只读打开失败:{st["error"]}(可能正在被写入)')
321
+ else:
322
+ add('数据库文件', 'ok', f'{db_path}({st["db_bytes"]} 字节)',
323
+ {'rows_by_source': st['rows_by_source']})
324
+ total_rows = sum(st['rows_by_source'].values())
325
+ if total_rows == 0:
326
+ add('快照数据', 'warn', '0 行(库为空 → 运行 mpdt fetch 建立首份快照)')
327
+ else:
328
+ latest = st['latest_snapshot']
329
+ days_ago = None
330
+ if latest:
331
+ try:
332
+ days_ago = (today_shanghai() - dt.date.fromisoformat(latest)).days
333
+ except ValueError:
334
+ pass
335
+ add('快照数据', 'ok' if days_ago is not None and days_ago <= 7 else 'warn',
336
+ f'共 {total_rows} 行,最近快照日 {latest}'
337
+ + (f'({days_ago} 天前)' if days_ago is not None else '')
338
+ + ('' if days_ago is not None and days_ago <= 7 else ' → 建议运行 mpdt fetch 更新'),
339
+ {'latest_snapshot': latest, 'days_ago': days_ago})
340
+
341
+ # ---- 人读输出(--json 走 r.result 通道)----
342
+ if not JSON_FLAG[0]:
343
+ icon = {'ok': '✓', 'warn': '⚠', 'fail': '✗'}
344
+ print('mpdt 体检(只读,不改任何状态):')
345
+ for it in items:
346
+ print(f' {icon[it["status"]]} {it["name"]}:{it["detail"]}')
347
+ n_fail = sum(1 for i in items if i['status'] == 'fail')
348
+ n_warn = sum(1 for i in items if i['status'] == 'warn')
349
+ return r.result(f'体检完成:{len(items)} 项,✓ {len(items)-n_fail-n_warn} / ⚠ {n_warn} / ✗ {n_fail}',
350
+ {'items': items, 'checks': len(items),
351
+ 'ok_count': len(items) - n_fail - n_warn,
352
+ 'warn_count': n_warn, 'fail_count': n_fail},
353
+ all_ok=n_fail == 0)
354
+
355
+
356
+ def cmd_status(args):
357
+ """本地库状态一览(只读):db 位置 / 各源行数 / 最新快照日 / 库体积。"""
358
+ r = RES[0]
359
+ db_path = config.DEFAULTS['db_path']
360
+ cfg_exists = os.path.exists(config.CONFIG_PATH)
361
+ if cfg_exists:
362
+ try:
363
+ db_path = config.load()['db_path']
364
+ except Exception:
365
+ db_path = config.DEFAULTS['db_path'] # 解析失败→回退默认路径并照常汇报
366
+ st = _db_readonly_stats(db_path)
367
+ db_bytes = st['db_bytes'] or 0
368
+ size_h = f'{db_bytes/1024:.1f} KB' if db_bytes < 1024 * 1024 else f'{db_bytes/1048576:.2f} MB'
369
+ data = {'db_path': db_path, 'db_exists': st['exists'], 'db_bytes': db_bytes,
370
+ 'db_size_human': size_h, 'rows_by_source': st['rows_by_source'],
371
+ 'latest_snapshot': st['latest_snapshot']}
372
+ if not JSON_FLAG[0]:
373
+ print(f'数据库:{db_path}' + ('' if st['exists'] else '(尚不存在,首次 fetch 时创建)'))
374
+ if st['exists']:
375
+ for src, n in st['rows_by_source'].items():
376
+ print(f' {src:<18} {n} 行')
377
+ print(f' 最新快照日:{st["latest_snapshot"] or "(空库)"}')
378
+ print(f' 库体积:{size_h}({db_bytes} 字节)')
379
+ return r.result(f'库状态:{db_path}({sum(st["rows_by_source"].values())} 行,{size_h})', data)
380
+
381
+
382
+ def cmd_metrics(args):
383
+ """指标口径查询:按概念分组的人读视图(口径唯一事实源=metrics.py CONCEPTS)。
384
+
385
+ 输出顺序:① 概念 + series;② 元数据字段;③ 旧版字段;④ 自动配对提醒。
386
+ """
387
+ r = RES[0]
388
+ concepts = metrics_mod.to_concepts_view(args.query)
389
+ if not JSON_FLAG[0]:
390
+ # ① 指标概念 + series
391
+ for c in concepts:
392
+ print('## %s(%s)' % (c['display'], c['concept']))
393
+ if c.get('note'):
394
+ print(' > ' + c['note'])
395
+ for s in c['series']:
396
+ print(' - `%s` · %s · 字段 `%s` · 窗口 %s (%d天) · 分母「%s」' % (
397
+ s['key'], s['source'], s['field'], s['window'], s['window_days'],
398
+ s['denominator']))
399
+ if s.get('note'):
400
+ print(' 注:' + s['note'])
401
+ print()
402
+ # ② 元数据字段(非指标)— 仅当 query 不限 concept / 匹配元数据 key 时显示
403
+ if not args.query or any(args.query.lower() in k.lower() or args.query.lower() in m['display']
404
+ for k, m in metrics_mod.META_FIELDS.items()):
405
+ print('── 元数据字段(非指标:快照日 / 派生列 / 结构列) ──')
406
+ for mname, m in metrics_mod.META_FIELDS.items():
407
+ if args.query and args.query.lower() not in (mname + m['display']).lower():
408
+ continue
409
+ for skey, s in m['series'].items():
410
+ print(' - `%s`(%s / %s):源 %s,字段 `%s`,%s,%d天窗口' % (
411
+ mname, m['display'], m['kind'], s['source'], s['field'],
412
+ s['window'], s['window_days']))
413
+ print()
414
+ # ③ 旧版字段
415
+ if metrics_mod.DEPRECATED and (not args.query or
416
+ any(args.query.lower() in (dn + d.get('display', '')).lower()
417
+ for dn, d in metrics_mod.DEPRECATED.items())):
418
+ print('── 旧版字段(保留登记;不参与对照 / 不出现在 metrics 视图) ──')
419
+ for dname, d in metrics_mod.DEPRECATED.items():
420
+ if args.query and args.query.lower() not in (dname + d.get('display', '')).lower():
421
+ continue
422
+ print(' - `%s`(%s)→ 新字段 `%s`;保留供 CSV 元数据识别上游历史返回。' % (
423
+ dname, d.get('display', ''), d.get('replaced_by', '?')))
424
+ print()
425
+ # ④ 自动配对提醒
426
+ pairs = metrics_mod.auto_pairs()
427
+ if pairs:
428
+ print('── 跨 series 易混淆配对(自动推导:同概念多 series) ──')
429
+ for cname, a, b, why in pairs:
430
+ display = metrics_mod.CONCEPTS[cname]['display']
431
+ if args.query and args.query.lower() not in (cname + display + a + b).lower():
432
+ continue
433
+ print('⚠ %s:`%s` vs `%s` — %s' % (cname, a, b, why))
434
+ return r.result('共 %d 个概念,%d 个 series;%d 个元数据字段;%d 个旧版字段' % (
435
+ len(concepts), sum(len(c['series']) for c in concepts),
436
+ len(metrics_mod.META_FIELDS), len(metrics_mod.DEPRECATED)),
437
+ {'concepts': concepts, 'auto_pairs': [
438
+ {'concept': c, 'series_a': a, 'series_b': b, 'why': why}
439
+ for c, a, b, why in metrics_mod.auto_pairs()]},
440
+ total_concepts=len(concepts),
441
+ total_series=sum(len(c['series']) for c in concepts),
442
+ total_meta=len(metrics_mod.META_FIELDS),
443
+ total_deprecated=len(metrics_mod.DEPRECATED),
444
+ query=args.query or '')
445
+
446
+
447
+ def cmd_export(args):
448
+ r = RES[0]
449
+ cfg = config.load()
450
+ con = store.connect(cfg['db_path'])
451
+ if args.format == 'json':
452
+ n = store.export_json(con, args.source, args.out, with_meta=not args.no_meta)
453
+ meta = {'out': args.out, 'rows': n, 'source': args.source, 'format': 'json'}
454
+ msg = f'JSON 导出:{args.out}({n} 行,source={args.source})'
455
+ if not args.no_meta:
456
+ msg += '(口径元数据:内嵌 metrics_meta)'
457
+ meta['metrics_meta'] = 'embedded'
458
+ else:
459
+ n = store.export_csv(con, args.source, args.out, with_meta=not args.no_meta)
460
+ meta = {'out': args.out, 'rows': n, 'source': args.source, 'format': 'csv'}
461
+ msg = f'CSV 导出:{args.out}({n} 行,source={args.source})'
462
+ if not args.no_meta:
463
+ msg += f'(口径元数据:{args.out}.metrics.csv)'
464
+ meta['metrics_meta'] = args.out + '.metrics.csv'
465
+ return r.result(msg, meta)
466
+
467
+
468
+ def main(argv=None):
469
+ p = argparse.ArgumentParser(
470
+ prog='mpdt',
471
+ formatter_class=argparse.RawDescriptionHelpFormatter,
472
+ description='公众号运营数据自动化工具(本地):复用本机已登录 Chrome 的登录态,\n'
473
+ '把发表列表 / datacube 文章分析 / 单篇渠道页快照进本地 SQLite,\n'
474
+ '再生成周报与导出。数据只存本机。',
475
+ epilog='三步快速上手:\n'
476
+ ' 1. mpdt init 生成本机配置 ~/.mpdt/config.json\n'
477
+ ' 2. mpdt fetch 快照数据进本地 SQLite(需 Chrome 已启动并登录 mp.weixin.qq.com)\n'
478
+ ' 3. mpdt report 生成本周周报(Markdown + HTML)\n'
479
+ '\n'
480
+ '常用补充命令:\n'
481
+ ' mpdt doctor 一键体检(只读:配置/Chrome/数据库逐项检查)\n'
482
+ ' mpdt status 查看库位置 / 各源行数 / 最新快照日 / 库体积\n'
483
+ ' mpdt metrics [关键字] 查指标口径("这个字段是什么口径")\n'
484
+ '\n'
485
+ '退出码:\n'
486
+ ' 0 成功(含空数据——当天数据 T+1 后才可得,空结果不是错误)\n'
487
+ ' 2 配置问题(未 init / 配置损坏)→ 下一步:mpdt init\n'
488
+ ' 3 凭据问题(token 刷新失败)→ 下一步:Chrome 里登录 mp.weixin.qq.com 后重试\n'
489
+ ' 4 上游接口异常(微信端点改版)→ 反馈原始返回片段给开发者\n'
490
+ ' 130 用户中断 Ctrl-C(已入库部分安全,幂等重跑)')
491
+ p.add_argument('--json', action='store_true',
492
+ help='机器可读 JSON 输出(stdout 仅一行 JSON;过程输出与提示全静默)')
493
+ p.add_argument('--version', action='version', version='%(prog)s ' + __version__,
494
+ help='显示版本号并退出')
495
+
496
+ sub = p.add_subparsers(dest='cmd', required=True,
497
+ metavar='<子命令>',
498
+ description='子命令(详细用法:mpdt <子命令> --help):')
499
+
500
+ # ---- init ----
501
+ pi = sub.add_parser(
502
+ 'init',
503
+ help='初始化本机配置(~/.mpdt/config.json,权限 600)',
504
+ description='初始化本机配置:生成 ~/.mpdt/config.json(权限 600,凭据只存本机)。\n'
505
+ '幂等——已存在时不覆盖,仅提示。这是首次使用的第一步。')
506
+ pi.add_argument('--db', default=None, metavar='PATH',
507
+ help='数据库文件路径(默认 ~/.mpdt/mpdt.sqlite3,父目录自动创建)')
508
+ pi.set_defaults(fn=cmd_init)
509
+
510
+ # ---- fetch ----
511
+ pf = sub.add_parser(
512
+ 'fetch', help='快照发表列表 + datacube 文章数据进本地库',
513
+ description='快照今日数据进本地 SQLite:①发表列表(实时累计指标);\n'
514
+ '②datacube 文章级分析(发表后 7 天窗口,T+1 出数——当天数据要明天才有)。\n'
515
+ '幂等:同一天重跑覆盖同键行。需要本机 Chrome 以调试端口启动且已登录。\n'
516
+ '新用户前置:mpdt init;拿不准环境就先跑 mpdt doctor。')
517
+ pf.add_argument('--from', dest='from_date', metavar='YYYY-MM-DD',
518
+ help='datacube 起始日(默认=回填窗口起点;与 --backfill 二选一)')
519
+ pf.add_argument('--to', dest='to_date', metavar='YYYY-MM-DD',
520
+ help='datacube 截止日(默认=昨天,T+1:当天数据未出)')
521
+ pf.add_argument('--backfill', type=int, default=1, metavar='N',
522
+ help='回填最近 N 天(默认 1=仅昨天;给 --from 时忽略)')
523
+ pf.add_argument('--count', type=int, default=20, metavar='N',
524
+ help='发表列表拉取条数(默认 20)')
525
+ pf.set_defaults(fn=cmd_fetch)
526
+
527
+ # ---- detail ----
528
+ pd = sub.add_parser(
529
+ 'detail', help='抓单篇渠道页(完读率 + 渠道来源构成,30 天窗口)',
530
+ description='抓单篇渠道分析页入库(article_detail 源):阅读人数/完读率/时长 +\n'
531
+ '逐渠道人数构成(推荐/搜一搜/公众号消息…),发表后 30 天窗口口径。\n'
532
+ '两种目标:指定单篇(--msgid + --publish-date),或按发表记录批量(--recent N)。')
533
+ pd.add_argument('--msgid', metavar='ID',
534
+ help='单个 appmsgid(必须同时给 --publish-date)')
535
+ pd.add_argument('--item-idx', type=int, default=1, metavar='N',
536
+ help='多图文中的第 N 篇(默认 1;与 --msgid 配对)')
537
+ pd.add_argument('--publish-date', metavar='YYYY-MM-DD',
538
+ help='该篇发表日期(与 --msgid 配对,渠道页接口必填)')
539
+ pd.add_argument('--recent', type=int, default=0, metavar='N',
540
+ help='抓最近 N 天发表的文章(0=不用此模式;走发表记录自动定位)')
541
+ pd.add_argument('--count', type=int, default=20, metavar='N',
542
+ help='发表记录拉取条数(--recent 模式下定位目标用,默认 20)')
543
+ pd.set_defaults(fn=cmd_detail)
544
+
545
+ # ---- report ----
546
+ pr = sub.add_parser(
547
+ 'report', help='生成周报(Markdown + HTML,含跨源口径对照)',
548
+ description='生成本周(或指定周)数据周报:完读率 Top10 + 跨 series 宽表对照 +\n'
549
+ '单篇渠道构成。文首自动带口径说明;HTML 带目录导航锚点。\n'
550
+ '前置:先跑过 mpdt fetch(空周也能出报告,只是标注无数据)。')
551
+ pr.add_argument('--week', metavar='ISO周',
552
+ help='ISO 周号,如 2026-W42(默认=本周)')
553
+ pr.add_argument('--out', metavar='DIR',
554
+ help='输出目录(默认 ./mpdt-reports)')
555
+ pr.set_defaults(fn=cmd_report)
556
+
557
+ # ---- export ----
558
+ pe = sub.add_parser(
559
+ 'export', help='导出快照为 CSV / JSON(附口径元数据)',
560
+ description='把某个数据源的快照导出为 CSV 或 JSON。默认附带口径元数据\n'
561
+ '(CSV:*.metrics.csv 伴随文件;JSON:内嵌 metrics_meta),\n'
562
+ '防止混源导出后分不清列口径。空源导出 0 行也算成功。')
563
+ pe.add_argument('--source', default='datacube_article', metavar='SRC',
564
+ choices=SOURCES,
565
+ help='数据源:{%(choices)s}(默认 %(default)s;先跑 mpdt status 看各源行数)')
566
+ pe.add_argument('--out', default='mpdt-export.csv', metavar='PATH',
567
+ help='输出文件路径(默认 %(default)s)')
568
+ pe.add_argument('--format', choices=['csv', 'json'], default='csv',
569
+ help='导出格式(默认 %(default)s;json=结构化载荷,口径元数据内嵌)')
570
+ pe.add_argument('--no-meta', action='store_true',
571
+ help='不写口径元数据(CSV 伴随文件 / JSON 内嵌块)')
572
+ pe.set_defaults(fn=cmd_export)
573
+
574
+ # ---- metrics ----
575
+ pm = sub.add_parser(
576
+ 'metrics', help='列出全部指标及口径(查"这个字段是什么口径")',
577
+ description='指标口径查询:按概念分组列出全部 series(源/字段/窗口/分母),\n'
578
+ '含元数据字段、旧版字段与易混淆配对。口径唯一事实源=metrics.py。')
579
+ pm.add_argument('query', nargs='?', default=None, metavar='关键字',
580
+ help='按 规范名/源字段/源名 子串过滤(如 完读率 / read_num)')
581
+ pm.set_defaults(fn=cmd_metrics)
582
+
583
+ # ---- doctor ----
584
+ po = sub.add_parser(
585
+ 'doctor', help='一键体检:配置/Chrome/数据库逐项检查(只读)',
586
+ description='一键体检(全部只读,不改任何状态):配置文件存在与可解析、权限 600、\n'
587
+ 'Chrome CDP 可达性、token 缓存状态、数据库文件与各源行数、最近快照新鲜度。\n'
588
+ '人读输出 ✓/✗/⚠ 清单(✗/⚠ 均附「下一步怎么办」);--json 输出同结构 data。\n'
589
+ '诊断本身永远 exit 0——体检结果不是命令成败。')
590
+ po.set_defaults(fn=cmd_doctor)
591
+
592
+ # ---- status ----
593
+ ps = sub.add_parser(
594
+ 'status', help='本地库状态:位置/各源行数/最新快照日/库体积(只读)',
595
+ description='本地库状态一览(只读):数据库文件在哪、三个源各多少行、\n'
596
+ '最新快照日、库体积。配置缺失时也能跑(回退默认路径)。')
597
+ ps.set_defaults(fn=cmd_status)
598
+
599
+ pre = p.parse_known_args(argv)[0] if argv is not None else None
600
+ args = p.parse_args(argv)
601
+ # 全局态:--json 与 TTY 检测(非 TTY=管道/重定向/cron,过程输出默认静默,结果仍打印)
602
+ JSON_FLAG[0] = args.json
603
+ CMD_NAME[0] = args.cmd
604
+ RES[0] = _R(quiet=not sys.stdout.isatty() and not args.json)
605
+ try:
606
+ rc = args.fn(args)
607
+ if args.json and rc == 0:
608
+ print(json.dumps(RES[0].payload(), ensure_ascii=False))
609
+ return rc
610
+ except KeyboardInterrupt:
611
+ # T1 三态:用户中断≠失败≠成功——130 独立退出码;已 upsert 的行随本命令 commit 状态而定,
612
+ # detail/fetch 的粒度级 commit 在循环外,故中断时该批未 commit(幂等重跑安全)。
613
+ print('[mpdt] 用户中断(exit 130):本次进度未完整 commit,重跑安全(幂等 upsert)。',
614
+ file=sys.stderr)
615
+ return 130
616
+ except MpdtError as e:
617
+ print(f'[mpdt] 错误(退出码 {e.exit_code}):{e}\n{e.hint}', file=sys.stderr)
618
+ if e.exit_code == 4:
619
+ print(f'[mpdt] 原始返回片段:{str(e)[:300]}', file=sys.stderr)
620
+ return e.exit_code
621
+
622
+
623
+ if __name__ == '__main__':
624
+ sys.exit(main())