| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160 |
- /**
- * 生意经「近30天门店数据」自动同步
- *
- * 用法(必须用 ego-browser 跑,因为它需要浏览器的登录态):
- * ego-browser nodejs store-ops/sync/life_sync.mjs
- * ego-browser nodejs store-ops/sync/life_sync.mjs --days 30 --dry-run
- *
- * 做的事:
- * 1. 打开生意经报表集市(复用浏览器里的登录态)
- * 2. 调 /api/lowcode_api/query 建一个「按统计周期、全部门店、41 个指标」的导出任务
- * 3. 轮询任务直到生成下载地址
- * 4. 下载 xlsx,文件名带上统计窗口(导入器据此按「近 N 天快照」入库)
- * 5. 落到 store-ops/data/inbox/,服务端 20 秒内自动导入
- */
- const fs = await import("node:fs/promises");
- const path = await import("node:path");
- const ARGS = (globalThis.SYNC_ARGS || process.argv.slice(2) || []);
- const opt = (name, def) => {
- const i = ARGS.indexOf("--" + name);
- return i >= 0 && ARGS[i + 1] && !ARGS[i + 1].startsWith("--") ? ARGS[i + 1] : def;
- };
- const DAYS = parseInt(opt("days", "30"), 10);
- const DRY = ARGS.includes("--dry-run");
- const POLL_TIMEOUT = parseInt(opt("timeout", "1200"), 10) * 1000; // 平台排队时间不稳定,放宽到 20 分钟
- const STORE_OPS = "/Users/haimian/Documents/deepseek-harness/default-workspace/门店检测工作台/store-ops";
- const INBOX = path.join(STORE_OPS, "data", "inbox");
- const ACCT = "7376197379193669659";
- const PAGE_ID = "15352";
- const PROJECT = "shengyijing";
- const IF_CREATE = "0ac0bacd";
- const IF_TASK = "d715cc19";
- const IF_QUOTA = "9e029004";
- // 41 个指标(与页面「门店」tab 全选一致)
- const INDICATORS = [
- "poi_id", "life_account_name", "lbs_city", "enter_poi_cnt", "enter_poi_uv_cnt",
- "click_poi_project_card_cnt_1d", "click_poi_project_card_uv_cnt_1d",
- "pay_cert_cnt_1d", "pay_user_cnt_1d", "pay_gmv_1d", "refund_amount_1d",
- "refund_cert_cnt_1d", "refund_user_cnt_1d", "pay_intention_gmv_1d",
- "pay_intention_cert_cnt_1d", "pay_intention_user_cnt_1d", "verify_amount_1d",
- "verify_cert_cnt_1d", "verify_user_cnt_1d", "verify_new_user_cnt_1d",
- "verify_old_user_cnt_1d", "refund_intention_gmv_1d", "refund_intention_cert_cnt_1d",
- "refund_intention_user_cnt_1d", "video_cnt_1d", "video_play_cnt_1d", "poi_score",
- "new_rate_cnt_1d", "positive_rate_cnt_1d", "normal_negative_rate",
- "all_new_rate_cnt", "all_positive_rate_cnt", "all_normal_negative_rate_cnt",
- "reply_rate_ratio", "cs_ticket_ratio", "bad_comment_ratio", "account_refund_order_ratio",
- "visible_checkin_cnt_1d", "favorite_cnt_1d", "rank_text", // 不再拉「点亮后的投稿数」:数据不可靠
- ];
- const POI_SELECTOR = JSON.stringify({
- isSelectAll: true, dimension: 101, type: "struct",
- structValue: JSON.stringify({
- ExpandToPoiAccount: true, SearchAllAccountPoiType: 6, SearchAllAccountPoiStatus: 0,
- Selections: [], PoiName: "", RelationTypes: [1, 12, 5], TagIDList: [],
- MainCategoryList: {}, SubCategoryList: {}, PoiAccountMarkets: [], PoiAccountMarks: [],
- PoiOpenStatus: [], PermissionKeyList: ["hermes.data.shengyijing_data_view"],
- StoreBizTagList: [], SxtSolutionStatusList: [], SxtSolutionPunishStatusList: [],
- SettleStatusBeforeClaim: [],
- }),
- });
- const pad = (n) => String(n).padStart(2, "0");
- const dstr = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
- const compact = (s) => s.replace(/-/g, "");
- const run = async () => {
- // ---- 统计窗口:今天往前 DAYS 天 ----
- const end = new Date();
- const start = new Date(end.getTime() - (DAYS - 1) * 86400000);
- const winStart = dstr(start);
- const winEnd = dstr(end);
- const now = new Date();
- const taskName = `同步_${compact(winEnd)}_${pad(now.getHours())}${pad(now.getMinutes())}`;
- console.log(`[1/5] 统计窗口 ${winStart} ~ ${winEnd}(${DAYS} 天)`);
- console.log(` 任务名 ${taskName}`);
- if (DRY) { console.log(" --dry-run,不实际建任务"); return; }
- const task = await taskSpace("生意经 每日同步");
- const page = task.page("p1");
- // ---- 打开页面,拿到登录态上下文 ----
- await page.goto("https://www.life-data.cn/data-bazaar/my/index", { waitUntil: "load", timeout: 30000 });
- await page.waitForTimeout(1500);
- const ok = await page.evaluate(() => !/登录|扫码/.test((document.body.innerText || "").slice(0, 300)));
- if (!ok) throw new Error("生意经未登录,请先在 ego 浏览器里登录一次");
- console.log("[2/5] 页面已就绪");
- // ---- 建任务 / 查任务 / 配额 都在页面上下文里发 ----
- const call = async (iid, dq) => page.evaluate(async ({ iid, dq, ACCT, PAGE_ID, PROJECT }) => {
- const r = await fetch(
- `/api/lowcode_api/query?projectKey=${PROJECT}&pageId=${PAGE_ID}&interface_id=${iid}`,
- { method: "POST", credentials: "include",
- headers: { accept: "application/json", "content-type": "application/json; charset=utf-8",
- "x-tt-trace-log": "01", "root-life-account-id": ACCT, "life-account-id": ACCT },
- body: JSON.stringify({ interface_id: iid, data_query: dq }) });
- return r.json();
- }, { iid, dq, ACCT, PAGE_ID, PROJECT });
- const quota = await call(IF_QUOTA, { data_query_type: "data_query",
- indicators: ["total", "available"], filters: null, group_by: [], extra: {} });
- const q = (quota.data || [])[0] || {};
- console.log(`[2/5] 今日剩余建任务次数:${q.available === undefined ? "?" : q.available}`);
- if (q.available !== undefined && Number(q.available) <= 0) throw new Error("今日建任务次数已用完");
- const created = await call(IF_CREATE, {
- data_query_type: "data_query",
- indicators: INDICATORS,
- filters: { operator: "and", filters: [
- { field: { name: "date" }, filter_operator: "between", range_values: [compact(winStart), compact(winEnd)] },
- { field: { name: "file_name" }, filter_operator: "equal", value: taskName },
- { field: { name: "ignore_zero_rows" }, filter_operator: "equal", value: 1 },
- { field: { name: "poi_selector_json" }, filter_operator: "equal", value: POI_SELECTOR },
- ] },
- group_by: [],
- order_by: [{ order_field: "day", order_type: "desc" }],
- extra: {},
- });
- if (created.code !== 0) throw new Error("建任务失败:" + (created.msg || JSON.stringify(created).slice(0, 200)));
- console.log(`[3/5] 任务已提交 ${created.task_id || ""}`);
- // ---- 轮询下载地址 ----
- const t0 = Date.now();
- let link = "";
- while (Date.now() - t0 < POLL_TIMEOUT) {
- await page.waitForTimeout(8000);
- const r = await call(IF_TASK, {
- data_query_type: "data_query",
- indicators: ["task_id", "task_name", "download_link", "create_time"],
- filters: { operator: "and", filters: [
- { field: { name: "query_task_name" }, filter_operator: "equal", value: taskName }] },
- group_by: [], extra: {},
- });
- const row = (r.data || [])[0];
- if (row && row.download_link) { link = row.download_link; break; }
- const secs = Math.round((Date.now() - t0) / 1000);
- console.log(` …等待生成(${secs}s)`);
- }
- if (!link) throw new Error("等待超时,任务未生成下载地址");
- console.log("[4/5] 下载地址已生成");
- // ---- 下载到收件箱(文件名带窗口,导入时按快照处理)----
- await fs.mkdir(INBOX, { recursive: true });
- const fname = `门店数据_${winStart}_${winEnd}.xlsx`;
- const target = path.join(INBOX, fname);
- await page.fetch(link, { saveAs: target });
- const st = await fs.stat(target);
- console.log(`[5/5] 已下载 ${fname}(${(st.size / 1024).toFixed(0)} KB)`);
- console.log(" 收件箱会在 20 秒内自动导入,导入后周期下拉会出现「近N天 · 截至 " + winEnd.slice(5) + "」");
- };
- try {
- await run();
- } catch (e) {
- console.error("✗ 同步失败:" + (e && e.message ? e.message : e));
- process.exitCode = 1;
- }
|