// ==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 ensureProgressToastStyle() { if (document.getElementById("yuntu-portrait-progress-toast-style")) { return } const style = document.createElement("style") style.id = "yuntu-portrait-progress-toast-style" style.textContent = ` .yuntu-portrait-progress-toast { overflow: hidden !important; } .yuntu-portrait-progress-toast .swal2-html-container { margin: 0.75em 0 0 !important; overflow: hidden !important; } ` document.head.appendChild(style) } 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 = "") { ensureProgressToastStyle() const toastOptions = { toast: true, position: "top-end", icon: "info", showConfirmButton: false, showCloseButton: false, allowOutsideClick: false, allowEscapeKey: false, title: "下载画像进度", html: this.renderProgressHtml(statusText, crowdName), width: 460, customClass: { popup: "yuntu-portrait-progress-toast" } } 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 || "下载画像失败") } } })()