// ==UserScript== // @name 云图报告同比复制助手 // @namespace https://yuntu.oceanengine.com/ // @version 0.1.0 // @description 记录最近一次成功创建的云图报告,并一键复制同比报告 // @author wangxi // @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketcreation* // @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketDetail/* // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant unsafeWindow // @connect localhost // @connect 127.0.0.1 // ==/UserScript== (function bootstrap(root, factory) { const api = factory(root); if (typeof module === "object" && module.exports) { module.exports = api; return; } api.init(); })(typeof globalThis !== "undefined" ? globalThis : this, function factory(root) { const TARGET_PATH = "/product_node/v2/api/segmentedMarket/createSegmentedMarket"; const LOCAL_API_BASES = ["http://localhost:3000", "http://127.0.0.1:3000"]; const SUPPORTED_PAGE_PATTERNS = [ /^\/yuntu_brand\/ecom\/product\/segmentedMarketcreation(?:\/.*)?$/, /^\/yuntu_brand\/ecom\/product\/segmentedMarketDetail\/.*$/, ]; const STORAGE_KEYS = { payload: "lastReportPayload", meta: "lastReportMeta", }; const EVENT_NAME = "yuntu-report-filling:capture"; const BUTTON_ID = "yuntu-report-filling-action"; const STYLE_ID = "yuntu-report-filling-style"; const INJECT_FLAG = "__yuntuReportFillingHooked__"; const runtimeState = { isSubmitting: false, suppressedRequestBody: null, clearSuppressionTimer: null, }; function log(message, extra) { if (extra === undefined) { console.log("[YuntuReportFilling]", message); return; } console.log("[YuntuReportFilling]", message, extra); } function isTargetCreateReportRequest(url, method) { if (String(method || "").toUpperCase() !== "POST") { return false; } try { const parsed = new URL(url, root.location && root.location.href ? root.location.href : "https://yuntu.oceanengine.com"); return parsed.pathname === TARGET_PATH && parsed.searchParams.has("aadvid"); } catch (_error) { return false; } } function isSupportedPageUrl(url) { try { const parsed = new URL( url, root.location && root.location.href ? root.location.href : "https://yuntu.oceanengine.com", ); return SUPPORTED_PAGE_PATTERNS.some((pattern) => pattern.test(parsed.pathname), ); } catch (_error) { return false; } } function getLocalApiBaseCandidates() { return [...LOCAL_API_BASES]; } function extractReportId(responseJson) { if (!responseJson || typeof responseJson !== "object") { return null; } const reportId = responseJson.data && responseJson.data.reportId; if (reportId === null || reportId === undefined || reportId === "") { return null; } return String(reportId); } function shiftDateBackOneYear(dateString) { const match = String(dateString).match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!match) { throw new Error(`Invalid date string: ${dateString}`); } const year = Number(match[1]); const month = Number(match[2]); const day = Number(match[3]); const targetYear = year - 1; const lastDay = new Date(Date.UTC(targetYear, month, 0)).getUTCDate(); const safeDay = Math.min(day, lastDay); return `${targetYear}-${String(month).padStart(2, "0")}-${String(safeDay).padStart(2, "0")}`; } function deepCloneJson(value) { return JSON.parse(JSON.stringify(value)); } function extractYear(dateString) { return String(dateString).slice(0, 4); } function buildAutoCopyName(originalName, startTime, endTime) { const baseName = originalName || ""; return `${baseName}${extractYear(startTime)}-${extractYear(endTime)}`; } function buildAutoCopyPayload(payload) { const cloned = deepCloneJson(payload); cloned.startTime = shiftDateBackOneYear(payload.startTime); cloned.endTime = shiftDateBackOneYear(payload.endTime); cloned.name = buildAutoCopyName(payload.name, cloned.startTime, cloned.endTime); return cloned; } function buildPersistRequest({ payload, aadvid, reportId, sourceType, sourceReportId, }) { return { reportId: String(reportId), sourceType, sourceReportId: sourceReportId ? String(sourceReportId) : null, aadvid: String(aadvid), name: payload.name || "", price: Array.isArray(payload.price) ? payload.price : [], rules: Array.isArray(payload.rules) ? payload.rules : [], analysisDims: Array.isArray(payload.analysisDims) ? payload.analysisDims : [], categories: Array.isArray(payload.categories) ? payload.categories : [], channels: Array.isArray(payload.channels) ? payload.channels : [], startTime: payload.startTime || "", endTime: payload.endTime || "", periodType: payload.periodType || null, userName: payload.userName || null, payload, }; } function parseJson(text) { return JSON.parse(text); } function getAadvidFromUrl(url) { try { const parsed = new URL(url, root.location.href); return parsed.searchParams.get("aadvid"); } catch (_error) { return null; } } function getCreateRequestUrl(meta) { if (meta && meta.requestUrl) { return meta.requestUrl; } const aadvid = (meta && meta.aadvid) || getAadvidFromUrl(root.location.href); if (!aadvid) { throw new Error("未获取到 aadvid"); } return `https://yuntu.oceanengine.com${TARGET_PATH}?aadvid=${encodeURIComponent(aadvid)}`; } function gmGetValueSafe(key, fallbackValue) { if (typeof GM_getValue !== "function") { return fallbackValue; } return GM_getValue(key, fallbackValue); } function gmSetValueSafe(key, value) { if (typeof GM_setValue !== "function") { throw new Error("GM_setValue 不可用"); } GM_setValue(key, value); } function readStoredPayload() { const rawValue = gmGetValueSafe(STORAGE_KEYS.payload, ""); if (!rawValue) { return null; } return parseJson(rawValue); } function readStoredMeta() { const rawValue = gmGetValueSafe(STORAGE_KEYS.meta, ""); if (!rawValue) { return null; } return parseJson(rawValue); } function writeStoredData(payload, meta) { gmSetValueSafe(STORAGE_KEYS.payload, JSON.stringify(payload)); gmSetValueSafe(STORAGE_KEYS.meta, JSON.stringify(meta)); } function clearSuppression() { runtimeState.suppressedRequestBody = null; if (runtimeState.clearSuppressionTimer) { root.clearTimeout(runtimeState.clearSuppressionTimer); runtimeState.clearSuppressionTimer = null; } } function suppressNextCapture(requestBodyText) { runtimeState.suppressedRequestBody = requestBodyText; if (runtimeState.clearSuppressionTimer) { root.clearTimeout(runtimeState.clearSuppressionTimer); } runtimeState.clearSuppressionTimer = root.setTimeout(() => { clearSuppression(); }, 10000); } function showToast(message, type) { const toast = root.document.createElement("div"); toast.className = `yuntu-report-filling-toast is-${type || "info"}`; toast.textContent = message; root.document.body.appendChild(toast); root.requestAnimationFrame(() => { toast.classList.add("is-visible"); }); root.setTimeout(() => { toast.classList.remove("is-visible"); root.setTimeout(() => { toast.remove(); }, 240); }, 2200); } function updateButtonState() { const button = root.document.getElementById(BUTTON_ID); if (!button) { return; } const hasPayload = Boolean(readStoredPayload()); button.disabled = runtimeState.isSubmitting || !hasPayload; button.textContent = runtimeState.isSubmitting ? "创建中..." : "一键复制同比报告"; button.dataset.enabled = String(hasPayload); } async function persistToLocalServer(payload) { const requestBody = JSON.stringify(payload); const bases = getLocalApiBaseCandidates(); let lastError = null; for (const base of bases) { try { if (typeof GM_xmlhttpRequest !== "function") { const response = await fetch(`${base}/api/reports`, { method: "POST", headers: { "Content-Type": "application/json", }, body: requestBody, }); const data = await response.json(); if (!response.ok || !data.success) { throw new Error((data.error && data.error.message) || "本地服务请求失败"); } return data; } const data = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "POST", url: `${base}/api/reports`, headers: { "Content-Type": "application/json", }, data: requestBody, onload(response) { try { const json = parseJson(response.responseText); if (response.status < 200 || response.status >= 300 || !json.success) { reject(new Error((json.error && json.error.message) || "本地服务请求失败")); return; } resolve(json); } catch (error) { reject(error); } }, onerror() { reject(new Error(`无法连接本地服务: ${base}`)); }, }); }); return data; } catch (error) { lastError = error; } } throw new Error( `本地服务未启动,请先在 server 目录执行 npm start。${lastError ? ` 原始错误: ${lastError.message}` : ""}`, ); } async function createReportThroughPage(url, payload) { const pageFetch = typeof unsafeWindow !== "undefined" && unsafeWindow.fetch ? unsafeWindow.fetch.bind(unsafeWindow) : root.fetch.bind(root); const requestBodyText = JSON.stringify(payload); suppressNextCapture(requestBodyText); const response = await pageFetch(url, { method: "POST", headers: { accept: "application/json, text/plain, */*", "content-type": "application/json", }, credentials: "include", body: requestBodyText, }); const data = await response.json(); if (!response.ok) { clearSuppression(); throw new Error((data && data.message) || `创建报告失败: ${response.status}`); } return { responseJson: data, requestBodyText, }; } async function handleButtonClick() { if (runtimeState.isSubmitting) { return; } const payload = readStoredPayload(); const meta = readStoredMeta(); if (!payload || !meta) { updateButtonState(); showToast("请先手动成功创建一次报告", "error"); return; } runtimeState.isSubmitting = true; updateButtonState(); try { const nextPayload = buildAutoCopyPayload(payload); const requestUrl = getCreateRequestUrl(meta); const sourceReportId = meta.reportId || null; const { responseJson } = await createReportThroughPage(requestUrl, nextPayload); const reportId = extractReportId(responseJson); if (!reportId) { throw new Error("创建成功,但未获取到 reportId"); } const nextMeta = { reportId, aadvid: meta.aadvid || getAadvidFromUrl(requestUrl), sourceType: "AUTO_COPY", capturedAt: new Date().toISOString(), requestUrl, }; writeStoredData(nextPayload, nextMeta); await persistToLocalServer( buildPersistRequest({ payload: nextPayload, aadvid: nextMeta.aadvid, reportId, sourceType: "AUTO_COPY", sourceReportId, }), ); showToast(`同比报告创建成功:${reportId}`, "success"); } catch (error) { log("自动创建同比报告失败", error); showToast(error.message || "同比报告创建失败", "error"); } finally { runtimeState.isSubmitting = false; updateButtonState(); } } async function handleCapturedRequest(detail) { if (!detail || !detail.requestBody || !detail.responseText) { return; } if (runtimeState.suppressedRequestBody && runtimeState.suppressedRequestBody === detail.requestBody) { clearSuppression(); return; } try { const payload = parseJson(detail.requestBody); const responseJson = parseJson(detail.responseText); const reportId = extractReportId(responseJson); if (!reportId) { return; } const aadvid = getAadvidFromUrl(detail.url) || getAadvidFromUrl(root.location.href); if (!aadvid) { throw new Error("未从请求中识别到 aadvid"); } const meta = { reportId, aadvid, sourceType: "MANUAL_CAPTURE", capturedAt: new Date().toISOString(), requestUrl: detail.url, }; writeStoredData(payload, meta); updateButtonState(); await persistToLocalServer( buildPersistRequest({ payload, aadvid, reportId, sourceType: "MANUAL_CAPTURE", sourceReportId: null, }), ); showToast(`已记录报告配置:${reportId}`, "success"); } catch (error) { log("记录手动创建报告失败", error); showToast(error.message || "记录报告配置失败", "error"); updateButtonState(); } } function ensureStyles() { if (root.document.getElementById(STYLE_ID)) { return; } const css = ` #${BUTTON_ID} { position: fixed; right: 24px; top: 50%; transform: translateY(-50%); z-index: 99999; width: 148px; min-height: 48px; padding: 10px 14px; border: none; border-radius: 14px; background: linear-gradient(135deg, #0f766e, #115e59); color: #ffffff; font-size: 14px; font-weight: 600; line-height: 1.4; box-shadow: 0 12px 30px rgba(15, 118, 110, 0.28); cursor: pointer; transition: transform 160ms ease, opacity 160ms ease, box-shadow 160ms ease; } #${BUTTON_ID}:hover:not(:disabled) { transform: translateY(-50%) translateX(-2px); box-shadow: 0 14px 32px rgba(15, 118, 110, 0.34); } #${BUTTON_ID}:disabled { cursor: not-allowed; opacity: 0.52; box-shadow: none; } .yuntu-report-filling-toast { position: fixed; top: 24px; right: 24px; z-index: 100000; max-width: 360px; padding: 12px 16px; border-radius: 12px; color: #ffffff; font-size: 13px; line-height: 1.5; box-shadow: 0 14px 36px rgba(15, 23, 42, 0.22); opacity: 0; transform: translateY(-8px); transition: opacity 180ms ease, transform 180ms ease; } .yuntu-report-filling-toast.is-visible { opacity: 1; transform: translateY(0); } .yuntu-report-filling-toast.is-success { background: #166534; } .yuntu-report-filling-toast.is-error { background: #b91c1c; } .yuntu-report-filling-toast.is-info { background: #1d4ed8; } `; if (typeof GM_addStyle === "function") { GM_addStyle(css); return; } const style = root.document.createElement("style"); style.id = STYLE_ID; style.textContent = css; root.document.head.appendChild(style); } function ensureButton() { if (root.document.getElementById(BUTTON_ID)) { return; } const button = root.document.createElement("button"); button.id = BUTTON_ID; button.type = "button"; button.textContent = "一键复制同比报告"; button.addEventListener("click", handleButtonClick); root.document.body.appendChild(button); updateButtonState(); } function injectCaptureHook() { if (!root.document || root.document.documentElement.getAttribute(INJECT_FLAG) === "true") { return; } const script = root.document.createElement("script"); script.textContent = ` (() => { if (window.${INJECT_FLAG}) { return; } window.${INJECT_FLAG} = true; const eventName = ${JSON.stringify(EVENT_NAME)}; const targetPath = ${JSON.stringify(TARGET_PATH)}; const isTarget = (url, method) => { try { const parsed = new URL(url, window.location.href); return String(method || "").toUpperCase() === "POST" && parsed.pathname === targetPath && parsed.searchParams.has("aadvid"); } catch (_error) { return false; } }; const serializeBody = (body) => { if (typeof body === "string") { return body; } if (!body) { return ""; } if (body instanceof URLSearchParams) { return body.toString(); } if (body instanceof FormData) { return ""; } try { return JSON.stringify(body); } catch (_error) { return ""; } }; const dispatchCapture = (detail) => { window.dispatchEvent(new CustomEvent(eventName, { detail })); }; const originalFetch = window.fetch; window.fetch = async function patchedFetch(input, init) { const requestUrl = input instanceof Request ? input.url : String(input); const method = (init && init.method) || (input instanceof Request ? input.method : "GET"); const requestBody = init && "body" in init ? serializeBody(init.body) : ""; const response = await originalFetch.apply(this, arguments); if (isTarget(requestUrl, method)) { try { const cloned = response.clone(); const responseText = await cloned.text(); dispatchCapture({ url: requestUrl, method, requestBody, responseText, status: response.status, }); } catch (_error) {} } return response; }; const originalOpen = XMLHttpRequest.prototype.open; const originalSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function patchedOpen(method, url) { this.__yuntuReportMethod = method; this.__yuntuReportUrl = url; return originalOpen.apply(this, arguments); }; XMLHttpRequest.prototype.send = function patchedSend(body) { this.__yuntuReportBody = serializeBody(body); this.addEventListener("loadend", () => { if (!isTarget(this.__yuntuReportUrl, this.__yuntuReportMethod)) { return; } dispatchCapture({ url: this.__yuntuReportUrl, method: this.__yuntuReportMethod, requestBody: this.__yuntuReportBody || "", responseText: typeof this.responseText === "string" ? this.responseText : "", status: this.status, }); }, { once: true }); return originalSend.apply(this, arguments); }; })(); `; root.document.documentElement.appendChild(script); script.remove(); root.document.documentElement.setAttribute(INJECT_FLAG, "true"); } function attachCaptureListener() { root.addEventListener(EVENT_NAME, (event) => { handleCapturedRequest(event.detail); }); } function init() { if (!isSupportedPageUrl(root.location && root.location.href ? root.location.href : "")) { return; } if (!root.document || !root.document.body) { root.addEventListener("DOMContentLoaded", init, { once: true }); return; } ensureStyles(); ensureButton(); attachCaptureListener(); injectCaptureHook(); updateButtonState(); } return { buildAutoCopyPayload, buildAutoCopyName, buildPersistRequest, extractReportId, getLocalApiBaseCandidates, init, isSupportedPageUrl, isTargetCreateReportRequest, shiftDateBackOneYear, }; });