/** * 生意经「近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; }