diff --git a/README.md b/README.md index c3369cd..f216b10 100755 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ clicks_diff_rate REAL, updated_at TEXT NOT NULL ## 输出协议 -- `data/YYYYMMDD.json`:直接 JSON 数组;最近 7 天每天生成一个文件,即使当天无数据也生成 `[]`。 -- `data/index.json`:包含 `schema_version`、`generated_at` 和最近 7 天的 `data/YYYYMMDD.json` 清单。 +- `data/YYYYMMDD.json`:直接 JSON 数组;默认最近 7 天每天生成一个文件,即使当天无数据也生成 `[]`。使用 `--all` 时只为数据库中实际有数据的日期生成文件。 +- `data/index.json`:包含 `schema_version`、`generated_at` 和本次实际生成的 `data/YYYYMMDD.json` 清单。 - 每行字段为 `dt`(`YYYYMMDD`)、`attr_platform`、`pkg_name`、`shareit_clicks`、`af_clicks`、`installs_ua`、`clicks_diff`、`clicks_diff_rate`;最后一项可以为 `null`。 默认日期边界为运行日往前 6 天至运行日,首尾包含,共 7 天。手工补数使用 `--start-date` 和 `--end-date`,两者必须同时提供且最多 7 天。 @@ -29,11 +29,15 @@ mac2 推荐始终通过包装脚本运行: cd /Users/shareit./Library/CloudStorage/OneDrive-个人/ubuntu/Gitea/af-click-gap scripts/run_pipeline.sh scripts/run_pipeline.sh --start-date 2026-10-10 --end-date 2026-10-16 +# 全量重建数据库中所有实际有数据的日期并 push +scripts/run_pipeline.sh --all ``` 包装脚本默认定位项目根目录,并读取 sibling 项目 `../direct-report-dashboard/.env`(mac2 默认路径为 `~/ubuntu/Gitea/af-click-gap` 与 `~/ubuntu/Gitea/direct-report-dashboard`)。也可以用 `PROJECT_ROOT` 和 `ENV_FILE` 覆盖路径。它只解析 `AGENTSTAGE_USER`、`AGENTSTAGE_TOKEN`,不执行整个 `.env`;凭据只进入子进程环境,不写入文件、remote URL、命令行参数或日志。脚本使用临时 `GIT_ASKPASS` 响应 Git 的用户名和密码提示,临时文件权限为仅当前用户可读写,并在退出时清理。 -不要直接运行 `python3 scripts/af_click_gap_pipeline.py --push`:该命令可能使用 mac2 Keychain 中没有仓库写权限的旧凭据。包装脚本会执行相同的 SQLite 最近 7 天提取和 `--push`,额外参数会继续传给流水线。需要只检查数据时可直接使用 `python3 scripts/af_click_gap_pipeline.py --dry-run`。 +不要直接运行 `python3 scripts/af_click_gap_pipeline.py --push`:该命令可能使用 mac2 Keychain 中没有仓库写权限的旧凭据。包装脚本会执行相同的 SQLite 最近 7 天提取和 `--push`,额外参数会继续传给流水线;因此 `scripts/run_pipeline.sh --all` 最终执行全量 `--all --push`。需要只检查数据时可直接使用 `python3 scripts/af_click_gap_pipeline.py --dry-run` 或 `python3 scripts/af_click_gap_pipeline.py --all --dry-run`。 + +`--all` 会读取数据库中 `pid='shareit'` 的全部记录并按实际出现的日期生成 JSON;它不能与日期范围参数同时使用。全量运行可能改动并提交大量历史数据,执行前应确认数据库内容和工作区,且仍会经过字段、日期、数值、JSON 和 Git 数据白名单校验。脚本对数据库使用 SQLite 只读连接,不建表、不迁移、不写入数据库。 `--dry-run` 只读取数据库并在仓库外临时目录生成、校验 JSON,不写 `data` 或 Git。正式运行先原子替换 `data/` 输出目录中的数据文件,再执行 Git;数据库查询或校验失败不会发布文件。Git 安全检查只允许本次生成的 `data/*.json`(含 `data/index.json`),工作区存在其它未提交改动时拒绝继续。push 失败会保留本地 commit,不会 force push。 diff --git a/scripts/af_click_gap_pipeline.py b/scripts/af_click_gap_pipeline.py index 61fd7b5..c4957f3 100755 --- a/scripts/af_click_gap_pipeline.py +++ b/scripts/af_click_gap_pipeline.py @@ -103,21 +103,33 @@ def validate_existing_schema(path: str) -> None: raise RuntimeError("click_gap 主键必须为 (pid, dt, pkg_name)") -def read_rows(path: str, start: str, end: str, pid: str = PID) -> list[dict[str, Any]]: +def read_rows(path: str, start: str | None, end: str | None, pid: str = PID) -> list[dict[str, Any]]: validate_existing_schema(path) try: with sqlite3.connect(f"file:{path}?mode=ro", uri=True) as conn: conn.row_factory = sqlite3.Row - result = conn.execute( + query = ( "SELECT dt, attr_platform, pkg_name, shareit_clicks, af_clicks, installs_ua, clicks_diff, clicks_diff_rate " - "FROM click_gap WHERE pid=? AND dt BETWEEN ? AND ? ORDER BY dt DESC, shareit_clicks DESC, pkg_name", - (pid, start, end), + "FROM click_gap WHERE pid=?" ) + parameters: list[str] = [pid] + if start is not None and end is not None: + query += " AND dt BETWEEN ? AND ?" + parameters.extend((start, end)) + query += " ORDER BY dt DESC, shareit_clicks DESC, pkg_name" + result = conn.execute(query, parameters) return [dict(row) for row in result] except sqlite3.Error as exc: raise RuntimeError(f"SQLite 查询失败: {exc}") from exc +def available_dates(rows: list[dict[str, Any]]) -> list[str]: + dates = set() + for row in rows: + dates.add(date8(str(row["dt"]))) + return sorted(dates, reverse=True) + + def validate_rows(rows: list[dict[str, Any]], start: str, end: str) -> list[dict[str, Any]]: normalized = [] for row in rows: @@ -213,6 +225,7 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Read SQLite click-gap data and publish JSON") parser.add_argument("--start-date", help="读取开始日期 YYYY-MM-DD") parser.add_argument("--end-date", help="读取结束日期 YYYY-MM-DD") + parser.add_argument("--all", action="store_true", help="读取数据库中所有实际有数据的日期") parser.add_argument("--dry-run", action="store_true", help="只读取、校验并生成临时 JSON,不写文件或 Git") group = parser.add_mutually_exclusive_group() group.add_argument("--no-push", action="store_true", help="写文件但不 push") @@ -226,11 +239,19 @@ def main() -> int: try: if args.dry_run and (args.no_push or args.push): raise ValueError("--dry-run 不可与 --push/--no-push 同时使用") - start, end = window(args.start_date, args.end_date) - rows = validate_rows(read_rows(args.db_path, start, end), start, end) - first = dt.datetime.strptime(start, "%Y%m%d").date() - last = dt.datetime.strptime(end, "%Y%m%d").date() - dates = [(first + dt.timedelta(days=i)).strftime("%Y%m%d") for i in range((last - first).days + 1)] + if args.all and (args.start_date or args.end_date): + raise ValueError("--all 不可与 --start-date/--end-date 同时使用") + if args.all: + rows = validate_rows(read_rows(args.db_path, None, None), "00010101", "99991231") + dates = available_dates(rows) + start = dates[-1] if dates else "无日期" + end = dates[0] if dates else "无日期" + else: + start, end = window(args.start_date, args.end_date) + rows = validate_rows(read_rows(args.db_path, start, end), start, end) + first = dt.datetime.strptime(start, "%Y%m%d").date() + last = dt.datetime.strptime(end, "%Y%m%d").date() + dates = [(first + dt.timedelta(days=i)).strftime("%Y%m%d") for i in range((last - first).days + 1)] repo = Path(__file__).resolve().parents[1] with tempfile.TemporaryDirectory(prefix="af-click-gap-") as temp: generated = Path(temp) / "data"