// ==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('
')}
`
}
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 || "下载画像失败")
}
}
})()