scriptCat/yuntu/yuntuReportFilling/yuntuReportFilling.user.js

717 lines
20 KiB
JavaScript

// ==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,
};
});