life_sync.mjs 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. /**
  2. * 生意经「近30天门店数据」自动同步
  3. *
  4. * 用法(必须用 ego-browser 跑,因为它需要浏览器的登录态):
  5. * ego-browser nodejs store-ops/sync/life_sync.mjs
  6. * ego-browser nodejs store-ops/sync/life_sync.mjs --days 30 --dry-run
  7. *
  8. * 做的事:
  9. * 1. 打开生意经报表集市(复用浏览器里的登录态)
  10. * 2. 调 /api/lowcode_api/query 建一个「按统计周期、全部门店、41 个指标」的导出任务
  11. * 3. 轮询任务直到生成下载地址
  12. * 4. 下载 xlsx,文件名带上统计窗口(导入器据此按「近 N 天快照」入库)
  13. * 5. 落到 store-ops/data/inbox/,服务端 20 秒内自动导入
  14. */
  15. const fs = await import("node:fs/promises");
  16. const path = await import("node:path");
  17. const ARGS = (globalThis.SYNC_ARGS || process.argv.slice(2) || []);
  18. const opt = (name, def) => {
  19. const i = ARGS.indexOf("--" + name);
  20. return i >= 0 && ARGS[i + 1] && !ARGS[i + 1].startsWith("--") ? ARGS[i + 1] : def;
  21. };
  22. const DAYS = parseInt(opt("days", "30"), 10);
  23. const DRY = ARGS.includes("--dry-run");
  24. const POLL_TIMEOUT = parseInt(opt("timeout", "1200"), 10) * 1000; // 平台排队时间不稳定,放宽到 20 分钟
  25. const STORE_OPS = "/Users/haimian/Documents/deepseek-harness/default-workspace/门店检测工作台/store-ops";
  26. const INBOX = path.join(STORE_OPS, "data", "inbox");
  27. const ACCT = "7376197379193669659";
  28. const PAGE_ID = "15352";
  29. const PROJECT = "shengyijing";
  30. const IF_CREATE = "0ac0bacd";
  31. const IF_TASK = "d715cc19";
  32. const IF_QUOTA = "9e029004";
  33. // 41 个指标(与页面「门店」tab 全选一致)
  34. const INDICATORS = [
  35. "poi_id", "life_account_name", "lbs_city", "enter_poi_cnt", "enter_poi_uv_cnt",
  36. "click_poi_project_card_cnt_1d", "click_poi_project_card_uv_cnt_1d",
  37. "pay_cert_cnt_1d", "pay_user_cnt_1d", "pay_gmv_1d", "refund_amount_1d",
  38. "refund_cert_cnt_1d", "refund_user_cnt_1d", "pay_intention_gmv_1d",
  39. "pay_intention_cert_cnt_1d", "pay_intention_user_cnt_1d", "verify_amount_1d",
  40. "verify_cert_cnt_1d", "verify_user_cnt_1d", "verify_new_user_cnt_1d",
  41. "verify_old_user_cnt_1d", "refund_intention_gmv_1d", "refund_intention_cert_cnt_1d",
  42. "refund_intention_user_cnt_1d", "video_cnt_1d", "video_play_cnt_1d", "poi_score",
  43. "new_rate_cnt_1d", "positive_rate_cnt_1d", "normal_negative_rate",
  44. "all_new_rate_cnt", "all_positive_rate_cnt", "all_normal_negative_rate_cnt",
  45. "reply_rate_ratio", "cs_ticket_ratio", "bad_comment_ratio", "account_refund_order_ratio",
  46. "visible_checkin_cnt_1d", "favorite_cnt_1d", "rank_text", // 不再拉「点亮后的投稿数」:数据不可靠
  47. ];
  48. const POI_SELECTOR = JSON.stringify({
  49. isSelectAll: true, dimension: 101, type: "struct",
  50. structValue: JSON.stringify({
  51. ExpandToPoiAccount: true, SearchAllAccountPoiType: 6, SearchAllAccountPoiStatus: 0,
  52. Selections: [], PoiName: "", RelationTypes: [1, 12, 5], TagIDList: [],
  53. MainCategoryList: {}, SubCategoryList: {}, PoiAccountMarkets: [], PoiAccountMarks: [],
  54. PoiOpenStatus: [], PermissionKeyList: ["hermes.data.shengyijing_data_view"],
  55. StoreBizTagList: [], SxtSolutionStatusList: [], SxtSolutionPunishStatusList: [],
  56. SettleStatusBeforeClaim: [],
  57. }),
  58. });
  59. const pad = (n) => String(n).padStart(2, "0");
  60. const dstr = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
  61. const compact = (s) => s.replace(/-/g, "");
  62. const run = async () => {
  63. // ---- 统计窗口:今天往前 DAYS 天 ----
  64. const end = new Date();
  65. const start = new Date(end.getTime() - (DAYS - 1) * 86400000);
  66. const winStart = dstr(start);
  67. const winEnd = dstr(end);
  68. const now = new Date();
  69. const taskName = `同步_${compact(winEnd)}_${pad(now.getHours())}${pad(now.getMinutes())}`;
  70. console.log(`[1/5] 统计窗口 ${winStart} ~ ${winEnd}(${DAYS} 天)`);
  71. console.log(` 任务名 ${taskName}`);
  72. if (DRY) { console.log(" --dry-run,不实际建任务"); return; }
  73. const task = await taskSpace("生意经 每日同步");
  74. const page = task.page("p1");
  75. // ---- 打开页面,拿到登录态上下文 ----
  76. await page.goto("https://www.life-data.cn/data-bazaar/my/index", { waitUntil: "load", timeout: 30000 });
  77. await page.waitForTimeout(1500);
  78. const ok = await page.evaluate(() => !/登录|扫码/.test((document.body.innerText || "").slice(0, 300)));
  79. if (!ok) throw new Error("生意经未登录,请先在 ego 浏览器里登录一次");
  80. console.log("[2/5] 页面已就绪");
  81. // ---- 建任务 / 查任务 / 配额 都在页面上下文里发 ----
  82. const call = async (iid, dq) => page.evaluate(async ({ iid, dq, ACCT, PAGE_ID, PROJECT }) => {
  83. const r = await fetch(
  84. `/api/lowcode_api/query?projectKey=${PROJECT}&pageId=${PAGE_ID}&interface_id=${iid}`,
  85. { method: "POST", credentials: "include",
  86. headers: { accept: "application/json", "content-type": "application/json; charset=utf-8",
  87. "x-tt-trace-log": "01", "root-life-account-id": ACCT, "life-account-id": ACCT },
  88. body: JSON.stringify({ interface_id: iid, data_query: dq }) });
  89. return r.json();
  90. }, { iid, dq, ACCT, PAGE_ID, PROJECT });
  91. const quota = await call(IF_QUOTA, { data_query_type: "data_query",
  92. indicators: ["total", "available"], filters: null, group_by: [], extra: {} });
  93. const q = (quota.data || [])[0] || {};
  94. console.log(`[2/5] 今日剩余建任务次数:${q.available === undefined ? "?" : q.available}`);
  95. if (q.available !== undefined && Number(q.available) <= 0) throw new Error("今日建任务次数已用完");
  96. const created = await call(IF_CREATE, {
  97. data_query_type: "data_query",
  98. indicators: INDICATORS,
  99. filters: { operator: "and", filters: [
  100. { field: { name: "date" }, filter_operator: "between", range_values: [compact(winStart), compact(winEnd)] },
  101. { field: { name: "file_name" }, filter_operator: "equal", value: taskName },
  102. { field: { name: "ignore_zero_rows" }, filter_operator: "equal", value: 1 },
  103. { field: { name: "poi_selector_json" }, filter_operator: "equal", value: POI_SELECTOR },
  104. ] },
  105. group_by: [],
  106. order_by: [{ order_field: "day", order_type: "desc" }],
  107. extra: {},
  108. });
  109. if (created.code !== 0) throw new Error("建任务失败:" + (created.msg || JSON.stringify(created).slice(0, 200)));
  110. console.log(`[3/5] 任务已提交 ${created.task_id || ""}`);
  111. // ---- 轮询下载地址 ----
  112. const t0 = Date.now();
  113. let link = "";
  114. while (Date.now() - t0 < POLL_TIMEOUT) {
  115. await page.waitForTimeout(8000);
  116. const r = await call(IF_TASK, {
  117. data_query_type: "data_query",
  118. indicators: ["task_id", "task_name", "download_link", "create_time"],
  119. filters: { operator: "and", filters: [
  120. { field: { name: "query_task_name" }, filter_operator: "equal", value: taskName }] },
  121. group_by: [], extra: {},
  122. });
  123. const row = (r.data || [])[0];
  124. if (row && row.download_link) { link = row.download_link; break; }
  125. const secs = Math.round((Date.now() - t0) / 1000);
  126. console.log(` …等待生成(${secs}s)`);
  127. }
  128. if (!link) throw new Error("等待超时,任务未生成下载地址");
  129. console.log("[4/5] 下载地址已生成");
  130. // ---- 下载到收件箱(文件名带窗口,导入时按快照处理)----
  131. await fs.mkdir(INBOX, { recursive: true });
  132. const fname = `门店数据_${winStart}_${winEnd}.xlsx`;
  133. const target = path.join(INBOX, fname);
  134. await page.fetch(link, { saveAs: target });
  135. const st = await fs.stat(target);
  136. console.log(`[5/5] 已下载 ${fname}(${(st.size / 1024).toFixed(0)} KB)`);
  137. console.log(" 收件箱会在 20 秒内自动导入,导入后周期下拉会出现「近N天 · 截至 " + winEnd.slice(5) + "」");
  138. };
  139. try {
  140. await run();
  141. } catch (e) {
  142. console.error("✗ 同步失败:" + (e && e.message ? e.message : e));
  143. process.exitCode = 1;
  144. }