From 735dd3ecf9979919a3109bc20e54b6679ce8329a Mon Sep 17 00:00:00 2001 From: wxs Date: Thu, 23 Apr 2026 20:59:30 +0800 Subject: [PATCH] feat(yuntu): update crowd portrait download script --- .../yuntuCrowdPortraitDownload.js | 286 ------- .../yuntuCrowdPortraitDownload.user.js | 803 ++++++++++++++++++ 2 files changed, 803 insertions(+), 286 deletions(-) delete mode 100644 yuntu/yuntuCrowdPortraitDownload/yuntuCrowdPortraitDownload.js create mode 100644 yuntu/yuntuCrowdPortraitDownload/yuntuCrowdPortraitDownload.user.js diff --git a/yuntu/yuntuCrowdPortraitDownload/yuntuCrowdPortraitDownload.js b/yuntu/yuntuCrowdPortraitDownload/yuntuCrowdPortraitDownload.js deleted file mode 100644 index f4c766e..0000000 --- a/yuntu/yuntuCrowdPortraitDownload/yuntuCrowdPortraitDownload.js +++ /dev/null @@ -1,286 +0,0 @@ -// ==UserScript== -// @name 批量下载人群画像 -// @namespace https://bbs.tampermonkey.net.cn/ -// @version 0.3.2 -// @description try to take over the world! -// @author wan喜 -// @match https://yuntu.oceanengine.com/yuntu_ng/analysis/audience/list?* -// @match https://yuntu.oceanengine.com/yuntu_brand/analysis/audience/list?* -// @icon https://www.google.com/s2/favicons?domain=oceanengine.com -// @require https://cdn.staticfile.net/sweetalert2/11.10.0/sweetalert2.all.min.js -// ==/UserScript== - -(function () { - "use strict" - - var newButton = document.createElement("button") - newButton.innerHTML = "下载画像" - newButton.type = "button" - newButton.style.height = "34px" - newButton.style.lineHeight = "34px"; - newButton.style.align = "center"; //文本居中 - newButton.style.color = "white"; //按钮文字颜色 - newButton.style.background = "#1f4bd9"; //按钮底色 - newButton.style.border = "1px solid #1f4bd9"; //边框属性 - newButton.style.borderRadius = "3px"; //按钮四个角弧度 - newButton.style.marginLeft = '10px'; - newButton.style.fontSize = '12px'; - newButton.style.padding = '0 10px'; - newButton.className = "byted-btn byted-btn-size-md byted-btn-type-primary byted-btn-shape-angle byted-can-input-grouped" - newButton.style.marginLeft = "10px" - newButton.addEventListener("click", handleButtonClick) //监听按钮点击事件 - - function appendDoc() { - setTimeout(() => { - var a = document.querySelector(".rowFlexBox-GWvhwN") - if (a) { - a.append(newButton) - } - appendDoc() - }, 1000) - } - appendDoc() - - const appID = "1128" //抖音 - const neededFeatureNames = ['8大消费群体', '预测年龄段', '预测性别', '城市级别'] //需要的画像维度 - - // 在脚本的顶部定义一个全局变量 - var selectedCrowds = { - crowdIdList: [], - crowdNameList: [] - }; - - - - function getID() { - //从url中获取aadvid - const url = window.location.href - const aadvid = url.split('aadvid=')[1] - - // 选择所有选中的复选框所在的行 - const checkedRows = document.querySelectorAll('.yuntu_analysis-Table-Body .yuntu_analysis-Table-Row .yuntu_analysis-checkbox-checked') - console.log(checkedRows) - let crowdNameList = [] - let crowdIdList = [] - var invalidCrowds = [] - - checkedRows.forEach(row => { - // 获取人群ID,人群ID位于操作按钮的 data-log-value 属性中 - const crowdId = row.closest('tr').querySelector('.operationItem-lHPsZh[data-log-value]').getAttribute('data-log-value') - - // 获取人群名称,人群名称位于带有text-D50wBP类的div中 - const crowdName = row.closest('tr').querySelector('.text-D50wBP').textContent.trim() - - //获取人群包画像操作按钮 - const crowdProfileOperate = row.closest('tr').querySelector('.operationItem-lHPsZh[data-log-label]') - - //获取人群包画像状态 - const crowdProfileStatus = crowdProfileOperate.textContent.trim() - - // 检查画像状态是否满足条件 - if (crowdProfileStatus === "查看画像" && !crowdProfileOperate.classList.contains('disabled-I30WTJ')) { //如果是计算中的人群包,class中会有disabled-I30WTJ - crowdIdList.push(crowdId) - crowdNameList.push(crowdName) - } else { - console.log(crowdProfileStatus) - - invalidCrowds.push(crowdName) - } - }) - - let crowdList = { crowdIdList, crowdNameList } - console.log('crowdList:' + JSON.stringify(crowdList)) - console.log('invalidCrowds:' + invalidCrowds.join(',')) - - return { - aadvid, - crowdList - } - } - - async function fetchData(url, method, body = null) { - const headers = { - "content-type": "application/json" - } - - const options = { - method: method, - headers: headers - } - - if (body) { - options.body = JSON.stringify(body) - } - - const response = await fetch(url, options) - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } else { - const json = await response.json() - return json - //只返回data以下的部分 - } - } - - - - - async function fetchProfile(aadvid, crowdId, crowdName) { - const url = "https://yuntu.oceanengine.com/yuntu_ng/api/v1/LabelDistribution?aadvid=" + aadvid + "&audience_id=" + crowdId + "&app=" + appID + "&page=1&page_size=100000&features=&label_all_cnt_filter=" - const profileJson = await fetchData(url, "GET") - const profileArr = profileJson.data.labels - const processedArr = profileArr - .filter(item => neededFeatureNames.includes(item.feature_name)) - .sort((a, b) => neededFeatureNames.indexOf(a.feature_name) - neededFeatureNames.indexOf(b.feature_name)) - .map(item => { - let result = { - feature_name: item.feature_name, - label_name: item.label_name - } - result[crowdName] = String(item.rate) // todo: crowdId 改成crowdName - return result - }) - - return processedArr - } - - function mergeArr(arr) { - let mergedArray = [] - let map = new Map() - - arr.forEach(subArr => { - subArr.forEach(item => { - let key = item['feature_name'] + '-' + item['label_name'] - let existingItem = map.get(key) - if (existingItem) { - existingItem = { ...existingItem, ...item } - } else { - existingItem = { ...item } - } - map.set(key, existingItem) - }) - }) - - map.forEach(value => { - mergedArray.push(value) - }) - - return mergedArray - } - - class chainFunction { - async msg() { - const Toast = Swal.mixin({ - toast: true, - position: "top-end", - showConfirmButton: false, - timer: 3000, - }) - Toast.fire({ - icon: "success", - title: "任务开始" - }) - } - async getProfile() { - let profileArr = [] - for (let i = 0; i < this.crowdIdList.length; i++) { - let neededProfile = await fetchProfile(this.aadvid, this.crowdIdList[i], this.crowdNameList[i]) - console.log(neededProfile) //所需要的画像维度 - profileArr.push(neededProfile) - } - console.log(profileArr) - this.profileArr = profileArr - return this - } - async getMergedProfile() { - let mergedProfile = mergeArr(this.profileArr) - this.mergedProfile = mergedProfile - console.log(mergedProfile) //合并后的所有画像 - return this - } - - async downloadCsv() { - let csv = ''; - let keys = ['feature_name', 'label_name', ...Object.keys(this.mergedProfile[0]).filter(key => key !== 'feature_name' && key !== 'label_name')] - csv += keys.join(',') + '\n' - for (let i = 0; i < this.mergedProfile.length; i++) { - let values = keys.map(key => this.mergedProfile[i][key]) - csv += values.join(',') + '\n' - } - var hiddenElement = document.createElement('a') - var fileName = this.crowdNameList.map(name => name.length > 5 ? name.slice(0, 5) : name).join('+') - hiddenElement.href = 'data:text/csv;charset=utf-8,%EF%BB%BF' + encodeURI(csv) - hiddenElement.target = '_blank' - hiddenElement.download = fileName.substring(0, 30) + ".csv" //只保留30个字符 防止太长Excel打不开 - hiddenElement.click() - } - } - - - - // 使用链式函数 - async function handleButtonClick() { - try { - let ID = getID() - // 对当前页面选中的人群包进行去重处理 - ID.crowdList.crowdIdList.forEach((crowdId, index) => { - if (!selectedCrowds.crowdIdList.includes(crowdId)) { - selectedCrowds.crowdIdList.push(crowdId) - selectedCrowds.crowdNameList.push(ID.crowdList.crowdNameList[index]) - } - }) - //处理不可用的人群包 - /* - if (ID.invalidCrowds.length > 0) { - const Toast = Swal.mixin({ - toast: true, - position: "top-end", - showConfirmButton: false, - timer: 3000, - }) - Toast.fire({ - title: "已自动剔除没有画像的人群包: " + ID.invalidCrowds.join(', '), - icon: "warning" - }) - ID.invalidCrowds = [] - } - */ - // 弹出 swal 对话框 - const { value } = await Swal.fire({ - title: '选择操作', - html: selectedCrowds.crowdNameList.join('
'), - icon: 'question', - showCancelButton: true, - confirmButtonText: '下载当前的人群', - cancelButtonText: '继续添加' - - }); - - - - if (value) { - // 如果用户选择直接下载 - let chain = new chainFunction(); - await chain.msg(); - chain.aadvid = ID.aadvid; - chain.crowdIdList = selectedCrowds.crowdIdList; - chain.crowdNameList = selectedCrowds.crowdNameList; - await chain.getProfile(); - await chain.getMergedProfile(); - await chain.downloadCsv(); - // 下载后清空全局变量 - selectedCrowds.crowdIdList = []; - selectedCrowds.crowdNameList = []; - } else { - // 如果用户选择继续添加人群包,什么也不做,允许用户切换页面继续选择 - } - } catch (error) { - console.error(error); - } - } - - - - -})() \ No newline at end of file diff --git a/yuntu/yuntuCrowdPortraitDownload/yuntuCrowdPortraitDownload.user.js b/yuntu/yuntuCrowdPortraitDownload/yuntuCrowdPortraitDownload.user.js new file mode 100644 index 0000000..7160f8d --- /dev/null +++ b/yuntu/yuntuCrowdPortraitDownload/yuntuCrowdPortraitDownload.user.js @@ -0,0 +1,803 @@ +// ==UserScript== +// @name 批量下载人群画像 +// @namespace https://bbs.tampermonkey.net.cn/ +// @version 1.0.0 +// @description 批量下载巨量云图人群画像 +// @author 汪喜 +// @match https://yuntu.oceanengine.com/yuntu_ng/analysis/audience/list?* +// @match https://yuntu.oceanengine.com/yuntu_brand/analysis/audience/list?* +// @match https://yuntu.oceanengine.com/yuntu_brand/ecom/analysis/audience/list?* +// @icon https://www.google.com/s2/favicons?domain=oceanengine.com +// @require https://cdn.staticfile.net/sweetalert2/11.10.0/sweetalert2.all.min.js +// ==/UserScript== + +(function () { + "use strict" + + var newButton = document.createElement("button") + newButton.innerHTML = "下载画像" + newButton.type = "button" + newButton.style.height = "34px" + newButton.style.lineHeight = "34px"; + newButton.style.align = "center"; //文本居中 + newButton.style.color = "white"; //按钮文字颜色 + newButton.style.background = "#a52615"; //按钮底色 + newButton.style.border = "1px solid #a52615"; //边框属性 + newButton.style.borderRadius = "3px"; //按钮四个角弧度 + newButton.style.marginLeft = '10px'; + newButton.style.fontSize = '12px'; + newButton.style.padding = '0 10px'; + newButton.className = "byted-btn byted-btn-size-md byted-btn-type-primary byted-btn-shape-angle byted-can-input-grouped" + newButton.style.marginLeft = "10px" + newButton.addEventListener("click", handleButtonClick) //监听按钮点击事件 + + function appendDoc() { + setTimeout(() => { + const toolbar = document.querySelector(".rowFlexBox-oqJGZk, .rowFlexBox-GWvhwN") + if (toolbar && newButton.parentElement !== toolbar) { + toolbar.append(newButton) + } + appendDoc() + }, 1000) + } + appendDoc() + + const portraitFeatureOptions = [ + { + key: 'yuntu_city_level', + label: '城市级别', + requestKeys: ['yuntu_city_level'], + responseKeys: ['yuntu_city_level'], + responseZh: ['城市级别'] + }, + { + key: 'gender', + label: '预测性别', + requestKeys: ['gender'], + responseKeys: ['gender'], + responseZh: ['预测性别'] + }, + { + key: 'new_age_v2', + label: '预测年龄段', + requestKeys: ['new_age_v2'], + responseKeys: ['new_age_v2'], + responseZh: ['预测年龄段'] + }, + { + key: 'ecom_big8_audience', + label: '八大消费群体', + requestKeys: ['ecom_big8_audience'], + responseKeys: ['ecom_big8_audience'], + responseZh: ['八大消费群体', '8大消费群体'] + }, + { + key: 'consuming_capacity', + label: '预测消费能力', + requestKeys: ['consuming_capacity'], + responseKeys: ['consuming_capacity'], + responseZh: ['预测消费能力'] + }, + { + key: 'life_stage', + label: '预测人生阶段', + requestKeys: ['life_stage'], + responseKeys: ['life_stage'], + responseZh: ['预测人生阶段'] + }, + { + key: 'city', + label: '城市', + requestKeys: ['city'], + responseKeys: ['city'], + responseZh: ['城市'] + }, + { + key: 'province', + label: '省份', + requestKeys: ['province'], + responseKeys: ['province'], + responseZh: ['省份'] + }, + { + key: 'phone_price_preference', + label: '手机价格', + requestKeys: ['phone_price_preference'], + responseKeys: ['phone_price_preference'], + responseZh: ['手机价格'] + }, + { + key: 'douyin_active_user', + label: '抖音活跃用户', + requestKeys: ['douyin_active_user'], + responseKeys: ['douyin_active_user'], + responseZh: ['抖音活跃用户'] + } + ] + const defaultSelectedFeatureKeys = [ + 'yuntu_city_level', + 'gender', + 'new_age_v2', + 'ecom_big8_audience', + 'life_stage', + 'consuming_capacity' + ] + const industryVersion = "10005" + const pathId = "analysis_audience_portrait" + const runtimeContext = { + brandContext: null + } + + // 在脚本的顶部定义一个全局变量 + var selectedCrowds = { + crowdIdList: [], + crowdNameList: [], + coverNumberList: [] + }; + + + + function getCellText(row, colIndex, selector = null) { + const cell = row.querySelector(`td[aria-colindex="${colIndex}"]`) + if (!cell) { + return "" + } + + const target = selector ? cell.querySelector(selector) : cell + return target ? target.textContent.trim() : "" + } + + function getCrowdProfileOperate(row) { + const operationItems = Array.from(row.querySelectorAll('[data-log-label][data-log-value]')) + return operationItems.find(item => { + const label = item.getAttribute('data-log-label') || "" + const text = item.textContent.trim() + return label.includes('画像') || text.includes('画像') + }) || null + } + + function normalizeCoverNumber(text) { + const normalized = String(text || "").replace(/,/g, "").trim() + return normalized && normalized !== "-" ? normalized : "" + } + + function getID() { + const url = new URL(window.location.href) + const aadvid = url.searchParams.get('aadvid') || "" + + const checkedRows = document.querySelectorAll('.yuntu_analysis-Table-Body .yuntu_analysis-checkbox-checked') + console.log(checkedRows) + let crowdNameList = [] + let crowdIdList = [] + let coverNumberList = [] + var invalidCrowds = [] + + checkedRows.forEach(checkbox => { + const row = checkbox.closest('tr') + if (!row) { + return + } + + const crowdName = getCellText(row, 2, '.text-jIwxn6') || getCellText(row, 2) + const crowdId = getCellText(row, 3) || (getCrowdProfileOperate(row)?.getAttribute('data-log-value') || "") + const coverNumber = normalizeCoverNumber(getCellText(row, 4)) + const crowdProfileOperate = getCrowdProfileOperate(row) + const crowdProfileStatus = crowdProfileOperate ? crowdProfileOperate.textContent.trim() : "" + const crowdStatusText = getCellText(row, 5, '.txt-KZswxE') || getCellText(row, 5) + const isDisabled = crowdProfileOperate ? /disabled/.test(crowdProfileOperate.className) : true + + if (!crowdId || !crowdName || !crowdProfileOperate) { + invalidCrowds.push(crowdName || crowdId || '未知人群') + return + } + + if (crowdProfileStatus === "查看画像" && !isDisabled && crowdStatusText === "计算完成") { + crowdIdList.push(crowdId) + crowdNameList.push(crowdName) + coverNumberList.push(coverNumber) + } else { + console.log(`${crowdName} 状态不满足下载条件: ${crowdStatusText} / ${crowdProfileStatus}`) + invalidCrowds.push(crowdName) + } + }) + + let crowdList = { crowdIdList, crowdNameList, coverNumberList } + console.log('crowdList:' + JSON.stringify(crowdList)) + console.log('invalidCrowds:' + invalidCrowds.join(',')) + + return { + aadvid, + crowdList, + invalidCrowds + } + } + + function normalizeBrandMetadata(list) { + const candidates = [] + const seen = new Set() + + for (const item of Array.isArray(list) ? list : []) { + const industryId = String(item?.industry_id || "") + if (industryId.length !== 2) { + continue + } + + const brandId = String(item?.brand_id || "") + const brandName = String(item?.brand_name || "") + const industryName = String(item?.industry_name || "") + const key = `${brandId}|${industryId}` + + if (!brandId || !industryId || seen.has(key)) { + continue + } + + seen.add(key) + candidates.push({ + key, + brandId, + brandName, + industryId, + industryName + }) + } + + return candidates + } + + async function requestJson(url, { method = "GET", body = null, headers = {}, referrer = window.location.href } = {}) { + const response = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : null, + credentials: "include", + mode: "cors", + referrer + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + return response.json() + } + + async function resolveBrandContext(aadvid) { + if (runtimeContext.brandContext?.aadvid === aadvid) { + return runtimeContext.brandContext + } + + const response = await requestJson( + `https://yuntu.oceanengine.com/yuntu_ng/api/v1/get_user_info?aadvid=${encodeURIComponent(aadvid)}`, + { + method: "GET", + referrer: window.location.href + } + ) + const candidates = normalizeBrandMetadata(response?.data?.brandMetadata) + + if (candidates.length === 0) { + throw new Error("未从 get_user_info 中解析到品牌和行业信息") + } + + const selected = candidates[0] + + runtimeContext.brandContext = { + aadvid, + brandId: selected.brandId, + brandName: selected.brandName, + industryId: selected.industryId, + industryName: selected.industryName, + industryVersion + } + + return runtimeContext.brandContext + } + + function getPortraitReferrer(crowdId, aadvid) { + const url = new URL(window.location.href) + url.pathname = url.pathname.replace(/\/list$/, "/portrait_v2") + url.search = "" + url.searchParams.set("cid", crowdId) + url.searchParams.set("aadvid", aadvid) + return url.toString() + } + + function getPortraitHeaders() { + return { + "content-type": "application/json" + } + } + + async function fetchAudienceBcId(aadvid, crowdId, brandContext) { + const requestUrl = new URL("https://yuntu.oceanengine.com/yuntu_ng/api/v1/GetAudienceBcId") + requestUrl.searchParams.set("aadvid", aadvid) + requestUrl.searchParams.set("custom_audience_id", crowdId) + requestUrl.searchParams.set("brand_id", brandContext.brandId) + requestUrl.searchParams.set("level_1_industy_id", brandContext.industryId) + + const response = await requestJson(requestUrl.toString(), { + method: "GET", + referrer: getPortraitReferrer(crowdId, aadvid) + }) + + console.log("GetAudienceBcId response:", response) + + const bcId = String(response?.data?.bcId || "") + + if (!bcId) { + throw new Error(`未获取到人群 ${crowdId} 的 bcID`) + } + + return bcId + } + + function getFeatureOptionMap() { + return portraitFeatureOptions.reduce((acc, item) => { + acc[item.key] = item + return acc + }, {}) + } + + function getSelectedFeatureOptions(selectedFeatureKeys) { + const featureKeySet = new Set(selectedFeatureKeys) + return portraitFeatureOptions.filter(item => featureKeySet.has(item.key)) + } + + function buildFeatureRequestKeys(selectedFeatureKeys) { + const requestKeys = [] + + getSelectedFeatureOptions(selectedFeatureKeys).forEach(item => { + item.requestKeys.forEach(key => { + if (!requestKeys.includes(key)) { + requestKeys.push(key) + } + }) + }) + + return requestKeys + } + + function renderFeatureSelectorHtml(selectedFeatureKeys) { + const featureKeySet = new Set(selectedFeatureKeys) + const checkboxHtml = portraitFeatureOptions.map(item => { + const checked = featureKeySet.has(item.key) ? 'checked' : '' + return ` + + ` + }).join('') + + return ` +
+
+
已选人群
+
+ ${selectedCrowds.crowdNameList.join('
')} +
+
+
+
画像维度
+
+ ${checkboxHtml} +
+
+
+ ` + } + + async function showDownloadDialog() { + const result = await Swal.fire({ + title: '下载画像', + html: renderFeatureSelectorHtml(defaultSelectedFeatureKeys), + width: 560, + focusConfirm: false, + showCancelButton: true, + confirmButtonText: '开始下载', + cancelButtonText: '继续添加', + preConfirm: () => { + const selectedFeatureKeys = Array.from( + document.querySelectorAll('.portrait-feature-checkbox:checked') + ).map(item => item.value) + + if (selectedFeatureKeys.length === 0) { + Swal.showValidationMessage('至少选择一个画像维度') + return false + } + + return { + selectedFeatureKeys + } + } + }) + + if (!result.isConfirmed) { + return null + } + + return result.value + } + + function getPortraitLabel(item) { + return String(item?.nameZh ?? item?.nameEn ?? '').trim() + } + + function getPortraitRate(item) { + return String(item?.value ?? '') + } + + function getPortraitTgi(item) { + return String(item?.tgi ?? '') + } + + function matchFeatureOption(dimension, selectedFeatureKeys) { + const selectedOptions = getSelectedFeatureOptions(selectedFeatureKeys) + return selectedOptions.find(item => { + const nameEn = String(dimension?.nameEn || '') + const nameZh = String(dimension?.nameZh || '') + return item.responseKeys.includes(nameEn) || item.responseZh.includes(nameZh) + }) || null + } + + function extractPortraitRows(profileJson, crowdName, selectedFeatureKeys) { + const dimensions = Array.isArray(profileJson?.data?.data) ? profileJson.data.data : [] + const rows = [] + + dimensions.forEach(dimension => { + const featureOption = matchFeatureOption(dimension, selectedFeatureKeys) + if (!featureOption) { + return + } + + const tagDetailList = Array.isArray(dimension?.tagDetailList) ? dimension.tagDetailList : [] + tagDetailList.forEach(item => { + const labelName = getPortraitLabel(item) + if (!labelName) { + return + } + + const row = { + feature_name: featureOption.label, + label_name: labelName + } + row[`${crowdName}_占比`] = getPortraitRate(item) + row[`${crowdName}_TGI`] = getPortraitTgi(item) + rows.push(row) + }) + }) + + return rows + } + + async function fetchProfile(aadvid, crowdId, crowdName, coverNumber, brandContext, selectedFeatureKeys, progressTracker = null) { + const bcId = await fetchAudienceBcId(aadvid, crowdId, brandContext) + progressTracker?.completeRequest("bcID 已获取", crowdName) + const requestUrl = new URL("https://yuntu.oceanengine.com/yuntu_ng/api/v1/crowd_portrait/GetPortraitByBCID") + requestUrl.searchParams.set("aadvid", aadvid) + const requestBody = { + bcID: bcId, + featureEnNames: buildFeatureRequestKeys(selectedFeatureKeys), + appType: 1, + coverNumber: coverNumber || "0" + } + + console.log("GetPortraitByBCID request:", { + method: "POST", + url: requestUrl.toString(), + crowdId, + crowdName, + body: requestBody + }) + + progressTracker?.markProgress("请求画像", crowdName) + const profileJson = await requestJson(requestUrl.toString(), { + method: "POST", + headers: getPortraitHeaders(), + referrer: getPortraitReferrer(crowdId, aadvid), + body: requestBody, + }) + progressTracker?.completeRequest("画像已获取", crowdName) + + const featureOptionMap = getFeatureOptionMap() + const processedArr = extractPortraitRows(profileJson, crowdName, selectedFeatureKeys) + .sort((a, b) => { + const leftIndex = selectedFeatureKeys.indexOf( + Object.keys(featureOptionMap).find(key => featureOptionMap[key].label === a.feature_name) + ) + const rightIndex = selectedFeatureKeys.indexOf( + Object.keys(featureOptionMap).find(key => featureOptionMap[key].label === b.feature_name) + ) + return leftIndex - rightIndex + }) + + if (processedArr.length === 0) { + throw new Error(`未从新画像接口解析到 ${crowdName} 的画像数据`) + } + + return processedArr + } + + function mergeArr(arr) { + let mergedArray = [] + let map = new Map() + + arr.forEach(subArr => { + subArr.forEach(item => { + let key = item['feature_name'] + '-' + item['label_name'] + let existingItem = map.get(key) + if (existingItem) { + existingItem = { ...existingItem, ...item } + } else { + existingItem = { ...item } + } + map.set(key, existingItem) + }) + }) + + map.forEach(value => { + mergedArray.push(value) + }) + + return mergedArray + } + + function escapeHtml(text) { + return String(text ?? "").replace(/[&<>"']/g, char => { + const entityMap = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + } + return entityMap[char] || char + }) + } + + function sanitizeFileNamePart(text) { + return String(text ?? "") + .replace(/[\\/:*?"<>|]/g, "_") + .replace(/\s+/g, "") + .trim() + } + + function getCrowdSampleName(crowdNameList) { + const firstName = sanitizeFileNamePart(crowdNameList?.[0] || "人群") + return firstName.length > 8 ? `${firstName.slice(0, 8)}等` : firstName + } + + function formatTimestampForFileName(date = new Date()) { + const pad = value => String(value).padStart(2, "0") + return [ + date.getFullYear(), + pad(date.getMonth() + 1), + pad(date.getDate()), + "_", + pad(date.getHours()), + pad(date.getMinutes()), + pad(date.getSeconds()) + ].join("") + } + + function buildCsvFileName(brandName, crowdNameList) { + const brandPart = sanitizeFileNamePart(brandName || "品牌") + const crowdSamplePart = getCrowdSampleName(crowdNameList) + const crowdCountPart = `${crowdNameList.length}个` + const timestampPart = formatTimestampForFileName() + return `${brandPart}_${crowdSamplePart}_${crowdCountPart}_${timestampPart}.csv` + } + + function showToast(icon, title) { + const Toast = Swal.mixin({ + toast: true, + position: "top-end", + showConfirmButton: false, + timer: 3000, + timerProgressBar: true, + }) + + return Toast.fire({ + icon, + title + }) + } + + class chainFunction { + getProgressPercent() { + if (!this.totalRequestCount) { + return 0 + } + + return Math.round((this.completedRequestCount / this.totalRequestCount) * 100) + } + + renderProgressHtml(statusText, crowdName = "") { + const percent = this.getProgressPercent() + const remainingCount = Math.max(this.totalRequestCount - this.completedRequestCount, 0) + const crowdHtml = crowdName + ? `
当前人群:${escapeHtml(crowdName)}
` + : "" + + return ` +
+ ${crowdHtml} +
+ ${escapeHtml(statusText)} + ${this.completedRequestCount}/${this.totalRequestCount} +
+
+
+
+
+ 已完成 ${this.completedRequestCount} 次请求 + 剩余 ${remainingCount} 次请求 +
+
+ ` + } + + showProgressToast(statusText, crowdName = "") { + const toastOptions = { + toast: true, + position: "top-end", + icon: "info", + showConfirmButton: false, + showCloseButton: false, + allowOutsideClick: false, + allowEscapeKey: false, + title: "下载画像进度", + html: this.renderProgressHtml(statusText, crowdName) + } + + if (Swal.isVisible()) { + Swal.update(toastOptions) + return + } + + Swal.fire(toastOptions) + } + + async msg() { + this.completedRequestCount = 0 + this.showProgressToast("准备开始") + } + markProgress(statusText, crowdName = "") { + this.showProgressToast(statusText, crowdName) + } + completeRequest(statusText, crowdName = "") { + this.completedRequestCount += 1 + this.showProgressToast(statusText, crowdName) + } + async finish() { + if (Swal.isVisible()) { + Swal.close() + } + await showToast("success", `下载完成,共 ${this.crowdNameList.length} 个人群`) + } + async getProfile() { + let profileArr = [] + for (let i = 0; i < this.crowdIdList.length; i++) { + const crowdName = this.crowdNameList[i] + this.markProgress("请求 bcID", crowdName) + let neededProfile = await fetchProfile( + this.aadvid, + this.crowdIdList[i], + crowdName, + this.coverNumberList[i], + this.brandContext, + this.selectedFeatureKeys, + this + ) + console.log(neededProfile) //所需要的画像维度 + profileArr.push(neededProfile) + } + console.log(profileArr) + this.profileArr = profileArr + return this + } + async getMergedProfile() { + let mergedProfile = mergeArr(this.profileArr) + this.mergedProfile = mergedProfile + console.log(mergedProfile) //合并后的所有画像 + return this + } + + async downloadCsv() { + if (!this.mergedProfile || this.mergedProfile.length === 0) { + throw new Error("没有可下载的画像数据") + } + let csv = ''; + let keys = ['feature_name', 'label_name'] + this.crowdNameList.forEach(name => { + keys.push(`${name}_占比`) + keys.push(`${name}_TGI`) + }) + csv += keys.join(',') + '\n' + for (let i = 0; i < this.mergedProfile.length; i++) { + let values = keys.map(key => this.mergedProfile[i][key]) + csv += values.join(',') + '\n' + } + var hiddenElement = document.createElement('a') + var fileName = buildCsvFileName(this.brandContext?.brandName, this.crowdNameList) + hiddenElement.href = 'data:text/csv;charset=utf-8,%EF%BB%BF' + encodeURI(csv) + hiddenElement.target = '_blank' + hiddenElement.download = fileName + hiddenElement.click() + } + } + + + + // 使用链式函数 + async function handleButtonClick() { + let chain = null + try { + let ID = getID() + if (ID.crowdList.crowdIdList.length === 0) { + throw new Error("当前没有选中可下载画像的人群包") + } + // 对当前页面选中的人群包进行去重处理 + ID.crowdList.crowdIdList.forEach((crowdId, index) => { + if (!selectedCrowds.crowdIdList.includes(crowdId)) { + selectedCrowds.crowdIdList.push(crowdId) + selectedCrowds.crowdNameList.push(ID.crowdList.crowdNameList[index]) + selectedCrowds.coverNumberList.push(ID.crowdList.coverNumberList[index]) + } + }) + //处理不可用的人群包 + /* + if (ID.invalidCrowds.length > 0) { + const Toast = Swal.mixin({ + toast: true, + position: "top-end", + showConfirmButton: false, + timer: 3000, + timerProgressBar: true, + }) + Toast.fire({ + title: "已自动剔除没有画像的人群包: " + ID.invalidCrowds.join(', '), + icon: "warning" + }) + ID.invalidCrowds = [] + } + */ + const downloadConfig = await showDownloadDialog() + + if (downloadConfig) { + // 如果用户选择直接下载 + chain = new chainFunction(); + chain.aadvid = ID.aadvid; + chain.crowdIdList = selectedCrowds.crowdIdList; + chain.crowdNameList = selectedCrowds.crowdNameList; + chain.coverNumberList = selectedCrowds.coverNumberList; + chain.selectedFeatureKeys = downloadConfig.selectedFeatureKeys; + chain.totalRequestCount = 1 + chain.crowdIdList.length * 2; + await chain.msg(); + chain.markProgress("请求品牌信息"); + chain.brandContext = await resolveBrandContext(ID.aadvid); + chain.completeRequest("品牌信息已获取"); + await chain.getProfile(); + await chain.getMergedProfile(); + await chain.downloadCsv(); + await chain.finish(); + // 下载后清空全局变量 + selectedCrowds.crowdIdList = []; + selectedCrowds.crowdNameList = []; + selectedCrowds.coverNumberList = []; + } else { + // 如果用户选择继续添加人群包,什么也不做,允许用户切换页面继续选择 + } + } catch (error) { + console.error(error); + if (Swal.isVisible()) { + Swal.close() + } + await showToast("error", error?.message || "下载画像失败") + } + } + + + + +})()