scriptCat/yuntu/yuntuReportFilling/yuntuReportFilling.user.js
2026-04-13 21:08:30 +08:00

820 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==UserScript==
// @name 云图报告同比复制助手
// @namespace https://yuntu.oceanengine.com/
// @version 0.1.0
// @description 记录最近一次成功创建的云图报告,并一键复制同比报告
// @author wangxi
// @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketList*
// @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 PAGE_TYPES = {
list: "list",
creation: "creation",
detail: "detail",
};
// 后续如果后端部署到服务器,只需要修改这里的基础地址。
// 例如改成https://your-server.example.com
// 脚本会在这些基础地址后自动拼接 /api/reports。
const LOCAL_API_BASES = ["http://localhost:3000", "http://127.0.0.1:3000"];
const SUPPORTED_PAGE_PATTERNS = [
{
type: PAGE_TYPES.list,
pattern: /^\/yuntu_brand\/ecom\/product\/segmentedMarketList(?:\/.*)?$/,
},
{
type: PAGE_TYPES.creation,
pattern: /^\/yuntu_brand\/ecom\/product\/segmentedMarketcreation(?:\/.*)?$/,
},
{
type: PAGE_TYPES.detail,
pattern: /^\/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,
captureListenerAttached: false,
routeWatcherCleanup: 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 getSupportedPageType(url) {
try {
const parsed = new URL(
url,
root.location && root.location.href
? root.location.href
: "https://yuntu.oceanengine.com",
);
const matched = SUPPORTED_PAGE_PATTERNS.find(({ pattern }) =>
pattern.test(parsed.pathname),
);
return matched ? matched.type : null;
} catch (_error) {
return null;
}
}
function isSupportedPageUrl(url) {
return getSupportedPageType(url) !== null;
}
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(responseJson) {
return deepCloneJson(responseJson);
}
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 pageType = getSupportedPageType(root.location && root.location.href ? root.location.href : "");
const hasPayload = Boolean(readStoredPayload());
const buttonUiState = getButtonUiState({
pageType,
hasPayload,
isSubmitting: runtimeState.isSubmitting,
});
button.disabled = buttonUiState.disabled;
button.textContent = runtimeState.isSubmitting
? "创建中..."
: "一键复制同比报告";
button.dataset.enabled = String(!buttonUiState.disabled);
button.dataset.pageType = String(pageType || "");
}
function getButtonUiState({ pageType, hasPayload, isSubmitting }) {
if (!pageType) {
return {
shouldRender: false,
disabled: true,
};
}
if (pageType === PAGE_TYPES.list) {
return {
shouldRender: true,
disabled: true,
};
}
return {
shouldRender: true,
disabled: Boolean(isSubmitting || !hasPayload),
};
}
function removeButton() {
const button = root.document.getElementById(BUTTON_ID);
if (button) {
button.remove();
}
}
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 { 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(responseJson));
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(responseJson));
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 syncUiForCurrentPage() {
if (!root.document || !root.document.body) {
return;
}
const pageType = getSupportedPageType(root.location && root.location.href ? root.location.href : "");
const hasPayload = Boolean(readStoredPayload());
const buttonUiState = getButtonUiState({
pageType,
hasPayload,
isSubmitting: runtimeState.isSubmitting,
});
if (!buttonUiState.shouldRender) {
removeButton();
return;
}
ensureStyles();
ensureButton();
updateButtonState();
}
function createRouteChangeWatcher(targetRoot, onRouteChange) {
if (!targetRoot || !targetRoot.history || typeof onRouteChange !== "function") {
return function noop() {};
}
let timerId = null;
const schedule = () => {
if (timerId) {
targetRoot.clearTimeout(timerId);
}
timerId = targetRoot.setTimeout(() => {
timerId = null;
onRouteChange();
}, 0);
};
const originalPushState = targetRoot.history.pushState;
const originalReplaceState = targetRoot.history.replaceState;
targetRoot.history.pushState = function patchedPushState() {
const result = originalPushState.apply(this, arguments);
schedule();
return result;
};
targetRoot.history.replaceState = function patchedReplaceState() {
const result = originalReplaceState.apply(this, arguments);
schedule();
return result;
};
targetRoot.addEventListener("popstate", schedule);
return function dispose() {
if (timerId) {
targetRoot.clearTimeout(timerId);
timerId = null;
}
targetRoot.history.pushState = originalPushState;
targetRoot.history.replaceState = originalReplaceState;
targetRoot.removeEventListener("popstate", schedule);
};
}
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() {
if (runtimeState.captureListenerAttached) {
return;
}
root.addEventListener(EVENT_NAME, (event) => {
handleCapturedRequest(event.detail);
});
runtimeState.captureListenerAttached = true;
}
function ensureRouteWatcher() {
if (runtimeState.routeWatcherCleanup) {
return;
}
runtimeState.routeWatcherCleanup = createRouteChangeWatcher(root, () => {
syncUiForCurrentPage();
});
}
function init() {
if (!root.document || !root.document.body) {
root.addEventListener("DOMContentLoaded", init, { once: true });
return;
}
ensureRouteWatcher();
attachCaptureListener();
injectCaptureHook();
syncUiForCurrentPage();
}
return {
buildAutoCopyPayload,
buildAutoCopyName,
buildPersistRequest,
createRouteChangeWatcher,
extractReportId,
getButtonUiState,
getLocalApiBaseCandidates,
getSupportedPageType,
init,
isSupportedPageUrl,
isTargetCreateReportRequest,
shiftDateBackOneYear,
};
});