Compare commits
No commits in common. "main" and "0.0525.7" have entirely different histories.
@ -76,22 +76,8 @@ The pipeline uses the tag as the release version. Recommended format: `0.MMDD.N`
|
|||||||
5. Select the unzipped `dist/` folder.
|
5. Select the unzipped `dist/` folder.
|
||||||
6. Confirm the extension ID is `pkjopdibdnomhogjheclhnknmejccffg`.
|
6. Confirm the extension ID is `pkjopdibdnomhogjheclhnknmejccffg`.
|
||||||
|
|
||||||
## One-Time Bridge Upgrade
|
|
||||||
|
|
||||||
Some coworkers may still be using an older unpacked build whose popup cannot read the COS update manifest and only shows:
|
|
||||||
|
|
||||||
- `暂时无法检查更新`
|
|
||||||
|
|
||||||
For those users, ask them to do one manual bridge upgrade with the newest ZIP:
|
|
||||||
|
|
||||||
1. Download the newest `star-chart-search-enhancer-internal.zip`.
|
|
||||||
2. Unzip it and get the new `dist/` folder.
|
|
||||||
3. Re-load that `dist/` folder in `chrome://extensions`.
|
|
||||||
|
|
||||||
After this one-time bridge upgrade, future updates should continue using the same `dist/` layout.
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Keep `.local/extension-key.pem` private and backed up internally.
|
- Keep `.local/extension-key.pem` private and backed up internally.
|
||||||
- Do not commit or share the private key with people who only need to install the extension.
|
- Do not commit or share the private key with people who only need to install the extension.
|
||||||
- If the batch submit backend changes away from `localhost:8083`, update `scripts/manifest.mjs` before packaging.
|
- If the batch submit backend changes away from `192.168.31.21:8083`, update `scripts/manifest.mjs` before packaging.
|
||||||
|
|||||||
@ -1,685 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>星图插件工具栏改版样例</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
color-scheme: light;
|
|
||||||
--page: #f5f7fa;
|
|
||||||
--panel: #ffffff;
|
|
||||||
--line: #e7ebf0;
|
|
||||||
--text: #20242a;
|
|
||||||
--muted: #6b7280;
|
|
||||||
--soft: #f8fafc;
|
|
||||||
--brand: #ff2f6d;
|
|
||||||
--brand-dark: #85172d;
|
|
||||||
--brand-soft: #fff0f5;
|
|
||||||
--blue: #2563eb;
|
|
||||||
--green: #0f8a5f;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
min-width: 1180px;
|
|
||||||
background: var(--page);
|
|
||||||
color: var(--text);
|
|
||||||
font-family:
|
|
||||||
-apple-system,
|
|
||||||
BlinkMacSystemFont,
|
|
||||||
"Segoe UI",
|
|
||||||
sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
input,
|
|
||||||
select {
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar {
|
|
||||||
height: 58px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 28px;
|
|
||||||
padding: 0 24px;
|
|
||||||
background: rgba(255, 255, 255, 0.96);
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
font-size: 23px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
width: 34px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 7px;
|
|
||||||
background:
|
|
||||||
linear-gradient(135deg, #ff245f 0 38%, transparent 39%),
|
|
||||||
linear-gradient(45deg, #27c7f2 0 50%, #3664ff 51%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 28px;
|
|
||||||
color: #2f3540;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav .active {
|
|
||||||
color: var(--brand);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav .active::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 10px;
|
|
||||||
right: 10px;
|
|
||||||
bottom: -17px;
|
|
||||||
height: 4px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-actions {
|
|
||||||
margin-left: auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.small-btn,
|
|
||||||
.pink-btn {
|
|
||||||
height: 34px;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0 16px;
|
|
||||||
border: 1px solid #d8dee8;
|
|
||||||
background: #fff;
|
|
||||||
color: #303744;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pink-btn {
|
|
||||||
border-color: var(--brand);
|
|
||||||
background: var(--brand);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page {
|
|
||||||
padding: 18px 24px 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner {
|
|
||||||
height: 46px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
border-radius: 9px;
|
|
||||||
background: #fff;
|
|
||||||
color: #111827;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchbar,
|
|
||||||
.filters,
|
|
||||||
.results {
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchbar {
|
|
||||||
height: 72px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 0 24px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
height: 34px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 7px;
|
|
||||||
padding: 0 18px;
|
|
||||||
background: var(--brand-soft);
|
|
||||||
color: var(--brand);
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.active {
|
|
||||||
background: var(--brand);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-input {
|
|
||||||
width: 440px;
|
|
||||||
height: 36px;
|
|
||||||
border: 1px solid #ff8ab2;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 108px 1fr;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-side {
|
|
||||||
background: #fbfcfe;
|
|
||||||
border-right: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-side div {
|
|
||||||
height: 72px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-main {
|
|
||||||
padding: 16px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-row {
|
|
||||||
min-height: 38px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 18px;
|
|
||||||
border-bottom: 1px solid #f0f2f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-row:last-child {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-title {
|
|
||||||
width: 72px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip,
|
|
||||||
.soft-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 26px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.soft-chip {
|
|
||||||
background: var(--brand-soft);
|
|
||||||
color: var(--brand);
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip {
|
|
||||||
color: #414854;
|
|
||||||
}
|
|
||||||
|
|
||||||
.results {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-top {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 18px 24px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count strong {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-shell {
|
|
||||||
margin: 18px 24px 0;
|
|
||||||
border: 1px solid #dfe5ee;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 58px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
background: linear-gradient(180deg, #fff, #fbfcff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-cluster {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding-right: 16px;
|
|
||||||
border-right: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-btn {
|
|
||||||
height: 34px;
|
|
||||||
border: 1px solid #7b1a2d;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0 14px;
|
|
||||||
background: #7b1a2d;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 800;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-cluster {
|
|
||||||
min-width: 0;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding-left: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 34px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border: 1px solid #cfe0ff;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #eef5ff;
|
|
||||||
color: #2563eb;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 900;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select,
|
|
||||||
.input {
|
|
||||||
height: 34px;
|
|
||||||
border: 1px solid #d4dbe6;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fff;
|
|
||||||
color: #202938;
|
|
||||||
padding: 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select {
|
|
||||||
min-width: 104px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 120px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-body {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px 14px 14px;
|
|
||||||
background: #fbfcff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 34px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border: 1px solid #cfe0ff;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #eef5ff;
|
|
||||||
color: #2563eb;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 900;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-rule {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 34px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border: 1px solid #d9efe7;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f0fbf6;
|
|
||||||
color: var(--green);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 900;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-rule strong {
|
|
||||||
margin-left: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-rule small {
|
|
||||||
margin-left: 6px;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-grid {
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.and {
|
|
||||||
color: #0f8a5f;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 900;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto auto 1fr;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
height: 34px;
|
|
||||||
padding: 0 8px;
|
|
||||||
border: 1px solid #dbe2ec;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric span,
|
|
||||||
.metric b {
|
|
||||||
color: #667085;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric b {
|
|
||||||
color: var(--green);
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric input {
|
|
||||||
min-width: 0;
|
|
||||||
width: 58px;
|
|
||||||
height: 26px;
|
|
||||||
border: 0;
|
|
||||||
outline: 0;
|
|
||||||
color: #1f2937;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.native-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-left: auto;
|
|
||||||
padding-left: 16px;
|
|
||||||
border-left: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.native-btn {
|
|
||||||
height: 34px;
|
|
||||||
border: 1px solid #d8dee8;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fff;
|
|
||||||
color: #303744;
|
|
||||||
padding: 0 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-top: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
height: 64px;
|
|
||||||
padding: 0 20px;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
text-align: left;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
height: 46px;
|
|
||||||
background: #fbfcfe;
|
|
||||||
color: #5d6675;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.author {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, #f4b66b, #7660d6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.price {
|
|
||||||
color: var(--brand);
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1380px) {
|
|
||||||
.metric-grid {
|
|
||||||
padding-bottom: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header class="topbar">
|
|
||||||
<div class="brand"><span class="logo"></span>巨量星图</div>
|
|
||||||
<nav class="nav">
|
|
||||||
<span>首页</span>
|
|
||||||
<span>我的星图</span>
|
|
||||||
<span>找灵感</span>
|
|
||||||
<span class="active">找达人</span>
|
|
||||||
<span>找活动</span>
|
|
||||||
<span>助投放</span>
|
|
||||||
</nav>
|
|
||||||
<div class="top-actions">
|
|
||||||
<button class="small-btn">达人清单</button>
|
|
||||||
<button class="pink-btn">+ 发布任务</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="page">
|
|
||||||
<section class="banner">「精选品牌伙伴计划」优质达人合作推荐</section>
|
|
||||||
|
|
||||||
<section class="searchbar">
|
|
||||||
<button class="tab">内容找人</button>
|
|
||||||
<button class="tab active">昵称找人</button>
|
|
||||||
<input class="search-input" value="抖音 输入达人昵称、抖音号或星图ID" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="filters">
|
|
||||||
<aside class="filter-side">
|
|
||||||
<div>合作诉求</div>
|
|
||||||
<div>匹配度</div>
|
|
||||||
<div>性价比</div>
|
|
||||||
<div>主题推荐</div>
|
|
||||||
</aside>
|
|
||||||
<div class="filter-main">
|
|
||||||
<div class="filter-row">
|
|
||||||
<span class="filter-title">合作对象</span>
|
|
||||||
<span class="soft-chip">不限</span>
|
|
||||||
<span class="chip">明星</span>
|
|
||||||
<span class="soft-chip">短视频达人</span>
|
|
||||||
<span class="chip">短剧演员</span>
|
|
||||||
<span class="chip">短直达人</span>
|
|
||||||
</div>
|
|
||||||
<div class="filter-row">
|
|
||||||
<span class="filter-title">适配行业</span>
|
|
||||||
<span class="chip">不限</span>
|
|
||||||
<span class="soft-chip">品牌曝光</span>
|
|
||||||
<span class="chip">破圈种草</span>
|
|
||||||
<span class="chip">行动转化</span>
|
|
||||||
<span class="chip">品牌5A</span>
|
|
||||||
</div>
|
|
||||||
<div class="filter-row">
|
|
||||||
<span class="filter-title">达人类型</span>
|
|
||||||
<span class="soft-chip">不限</span>
|
|
||||||
<span class="chip">美妆</span>
|
|
||||||
<span class="chip">萌宠</span>
|
|
||||||
<span class="chip">测评</span>
|
|
||||||
<span class="chip">旅行</span>
|
|
||||||
<span class="chip">母婴亲子</span>
|
|
||||||
<span class="chip">科技数码</span>
|
|
||||||
<span class="chip">生活家居</span>
|
|
||||||
</div>
|
|
||||||
<div class="filter-row">
|
|
||||||
<span class="filter-title">内容主题</span>
|
|
||||||
<span class="soft-chip">不限</span>
|
|
||||||
<span class="chip">妆容改造</span>
|
|
||||||
<span class="chip">亲子育儿</span>
|
|
||||||
<span class="chip">精彩生活</span>
|
|
||||||
<span class="chip">手机数码</span>
|
|
||||||
<span class="chip">萌宠养护</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="results">
|
|
||||||
<div class="result-top">
|
|
||||||
<div class="count">找到 <strong>10000+</strong> 个达人</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="toolbar-shell" aria-label="插件工具栏改版样例">
|
|
||||||
<div class="toolbar-head">
|
|
||||||
<div class="action-cluster">
|
|
||||||
<button class="tool-btn">导出选中达人数据</button>
|
|
||||||
<button class="tool-btn">按星图ID导出</button>
|
|
||||||
<button class="tool-btn">选择字段</button>
|
|
||||||
<button class="tool-btn">提交批次</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="control-cluster">
|
|
||||||
<span class="label">视频口径</span>
|
|
||||||
<select class="select">
|
|
||||||
<option>星图视频</option>
|
|
||||||
<option>个人视频</option>
|
|
||||||
</select>
|
|
||||||
<select class="select">
|
|
||||||
<option>只看指派</option>
|
|
||||||
<option>不限指派</option>
|
|
||||||
</select>
|
|
||||||
<select class="select">
|
|
||||||
<option>排除营销</option>
|
|
||||||
<option>不排除营销</option>
|
|
||||||
</select>
|
|
||||||
<select class="select">
|
|
||||||
<option>近90天</option>
|
|
||||||
<option>近30天</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="status">批次提交成功</span>
|
|
||||||
|
|
||||||
<div class="native-actions">
|
|
||||||
<button class="native-btn">自定义指标</button>
|
|
||||||
<button class="native-btn">导出</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toolbar-body">
|
|
||||||
<div class="metric-title">传播指标筛选</div>
|
|
||||||
<div class="metric-rule">全部满足<strong>AND</strong><small>每项取值 ≥ 输入值</small></div>
|
|
||||||
<div class="metric-grid">
|
|
||||||
<label class="metric"><span>评论</span><b>≥</b><input value="1" /></label>
|
|
||||||
<span class="and">且</span>
|
|
||||||
<label class="metric"><span>时长</span><b>≥</b><input value="5" /></label>
|
|
||||||
<span class="and">且</span>
|
|
||||||
<label class="metric"><span>点赞</span><b>≥</b><input value="10" /></label>
|
|
||||||
<span class="and">且</span>
|
|
||||||
<label class="metric"><span>转发</span><b>≥</b><input value="0" /></label>
|
|
||||||
<span class="and">且</span>
|
|
||||||
<label class="metric"><span>完播率</span><b>≥</b><input value="1" /></label>
|
|
||||||
<span class="and">且</span>
|
|
||||||
<label class="metric"><span>互动率</span><b>≥</b><input value="0.1" /></label>
|
|
||||||
<span class="and">且</span>
|
|
||||||
<label class="metric"><span>播放中位数</span><b>≥</b><input value="1000" /></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th><input type="checkbox" /> 全选</th>
|
|
||||||
<th>达人信息</th>
|
|
||||||
<th>代表视频</th>
|
|
||||||
<th>达人类型</th>
|
|
||||||
<th>内容主题</th>
|
|
||||||
<th>粉丝数</th>
|
|
||||||
<th>预期CPM</th>
|
|
||||||
<th>完播率</th>
|
|
||||||
<th>21-60s报价</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td><input type="checkbox" /></td>
|
|
||||||
<td>
|
|
||||||
<div class="author">
|
|
||||||
<span class="avatar"></span>
|
|
||||||
<div>
|
|
||||||
<strong>柯铭</strong><br />
|
|
||||||
<span style="color: #7a8493">男 · 北京市 · 抖音精选</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>2 个视频</td>
|
|
||||||
<td>萌宠</td>
|
|
||||||
<td>国内旅行 / 昆虫科普</td>
|
|
||||||
<td>1,471.4w</td>
|
|
||||||
<td>43.5</td>
|
|
||||||
<td>26.2%</td>
|
|
||||||
<td class="price">¥600,000</td>
|
|
||||||
<td><button class="pink-btn">下单</button></td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,105 +0,0 @@
|
|||||||
# 星图达人视频传播数据导出 Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** 导出 CSV 前按配置调用 `get_author_spread_info`,追加个人视频和星图视频传播指标列。
|
|
||||||
|
|
||||||
**Architecture:** 新增独立的 spread-info 模块负责参数配置、URL、响应映射和并发加载;列表解析保留 `authorId`,额外保存 `spreadAuthorId` 作为 `o_author_id`;CSV exporter 只负责把已加载的 spread metrics 输出成列。导出入口在生成 CSV 前补齐 spread metrics。
|
|
||||||
|
|
||||||
**Tech Stack:** TypeScript, Chrome content script, Vitest, jsdom.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Spread Info Client And Mapping
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/content/market/spread-info.ts`
|
|
||||||
- Modify: `src/content/market/types.ts`
|
|
||||||
- Test: `tests/spread-info.test.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests**
|
|
||||||
|
|
||||||
Cover URL construction, label/header generation, response mapping, personal-video fixed params, and Xingtu-video multi-param configs.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run failing tests**
|
|
||||||
|
|
||||||
Run: `npx vitest run tests/spread-info.test.ts`
|
|
||||||
Expected: FAIL because `spread-info.ts` does not exist.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement spread-info module and types**
|
|
||||||
|
|
||||||
Implement typed configs, formatter helpers, response mapper, client, and limited-concurrency loader.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests**
|
|
||||||
|
|
||||||
Run: `npx vitest run tests/spread-info.test.ts`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
### Task 2: Preserve Spread Author ID From Search Rows
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/content/market/types.ts`
|
|
||||||
- Modify: `src/content/market/market-list-row.ts`
|
|
||||||
- Modify: `src/content/market/page-bridge.ts`
|
|
||||||
- Test: `tests/market-page-bridge.test.ts`
|
|
||||||
- Test: `tests/silent-export-controller.test.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests**
|
|
||||||
|
|
||||||
Verify `attribute_datas.id` is retained as `spreadAuthorId` and preferred over top-level `star_id` for spread-info requests.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run failing tests**
|
|
||||||
|
|
||||||
Run focused tests for row parsing and silent export.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement parser changes**
|
|
||||||
|
|
||||||
Store `spreadAuthorId` on snapshots and merge it in result store.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run focused tests**
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
### Task 3: CSV Columns
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/content/market/csv-exporter.ts`
|
|
||||||
- Test: `tests/csv-exporter.test.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests**
|
|
||||||
|
|
||||||
Verify spread headers append after backend metrics and blank cells are exported when metrics are absent.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Implement CSV spread columns**
|
|
||||||
|
|
||||||
Read `record.spreadMetrics` by generated header names.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run focused tests**
|
|
||||||
|
|
||||||
Run: `npx vitest run tests/csv-exporter.test.ts`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
### Task 4: Export Hydration
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/content/market/index.ts`
|
|
||||||
- Modify: `src/content/market/result-store.ts`
|
|
||||||
- Test: `tests/market-content-entry.test.ts`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests**
|
|
||||||
|
|
||||||
Verify export calls spread-info with `spreadAuthorId`, waits before CSV generation, preserves row order, and leaves blanks on failure.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Implement hydration**
|
|
||||||
|
|
||||||
Inject `loadSpreadMetrics` for tests, default to spread-info loader, and hydrate records before `buildCsv`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run focused tests**
|
|
||||||
|
|
||||||
Run focused content-entry tests.
|
|
||||||
|
|
||||||
### Task 5: Final Verification
|
|
||||||
|
|
||||||
- [ ] Run `npm test`.
|
|
||||||
- [ ] Run `npm run build`.
|
|
||||||
- [ ] Review `git diff`.
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
# 星图达人传播指标阈值筛选 Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** 在导出 CSV 和提交批次前,按用户选择的 spread-info 参数组合和指标阈值过滤达人。
|
|
||||||
|
|
||||||
**Architecture:** 工具栏负责读取筛选配置;`spread-info.ts` 提供单参数组合加载与阈值比较;`index.ts` 在 export range 收集后、CSV/批次 payload 生成前统一应用筛选。
|
|
||||||
|
|
||||||
**Tech Stack:** TypeScript, Chrome MV3 content script, Vitest, jsdom.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Toolbar Filter State
|
|
||||||
|
|
||||||
- [ ] 增加视频类别、指派、营销流量、数据范围和 7 个阈值输入控件。
|
|
||||||
- [ ] 增加 `readToolbarSpreadFilter` 读取并校验筛选配置。
|
|
||||||
- [ ] 测试个人视频时固定并禁用指派/营销流量。
|
|
||||||
|
|
||||||
### Task 2: Spread Filter Logic
|
|
||||||
|
|
||||||
- [ ] 在 `spread-info.ts` 增加单配置请求与阈值比较。
|
|
||||||
- [ ] 测试百分比显示值、秒、普通数字比较。
|
|
||||||
|
|
||||||
### Task 3: Export And Batch Integration
|
|
||||||
|
|
||||||
- [ ] 在导出和提交批次流程中调用筛选逻辑。
|
|
||||||
- [ ] 空阈值不触发筛选请求。
|
|
||||||
- [ ] 测试导出和提交批次都只保留满足阈值的达人。
|
|
||||||
|
|
||||||
### Task 4: Verification
|
|
||||||
|
|
||||||
- [ ] 运行 focused tests。
|
|
||||||
- [ ] 运行 `npm run build`。
|
|
||||||
@ -1,242 +0,0 @@
|
|||||||
# 星图达人视频传播数据导出需求文档
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
在现有星图达人 CSV 导出流程中,额外调用星图接口 `get_author_spread_info`,获取达人视频传播相关指标,并把这些指标追加到导出表格中。
|
|
||||||
|
|
||||||
因为同一个指标在不同参数组合下含义不同,所以导出字段名必须带上参数前缀。例如:
|
|
||||||
|
|
||||||
```text
|
|
||||||
只看指派_排除营销流量_星图视频_近30天_完播率
|
|
||||||
```
|
|
||||||
|
|
||||||
这个字段表示:它不是普通的“完播率”,而是在“只看指派 + 排除营销流量 + 星图视频 + 近30天”这组参数下获取到的完播率。
|
|
||||||
|
|
||||||
字段名前缀只体现会造成数据差异、且在当前导出中可变化的参数。固定不变的参数不用写进字段名前缀。
|
|
||||||
|
|
||||||
## 接口
|
|
||||||
|
|
||||||
调用接口:
|
|
||||||
|
|
||||||
```text
|
|
||||||
GET /gw/api/data_sp/get_author_spread_info
|
|
||||||
```
|
|
||||||
|
|
||||||
请求参数:
|
|
||||||
|
|
||||||
| 参数 | 含义 |
|
|
||||||
| --- | --- |
|
|
||||||
| `o_author_id` | 达人的星图 ID |
|
|
||||||
| `platform_source` | 固定传 `1` |
|
|
||||||
| `platform_channel` | 固定传 `1` |
|
|
||||||
| `type` | 视频类型 |
|
|
||||||
| `flow_type` | 是否排除营销流量 |
|
|
||||||
| `only_assign` | 是否只看指派 |
|
|
||||||
| `range` | 数据时间范围 |
|
|
||||||
|
|
||||||
请求需要带上当前星图网页登录态,所以实现时请求要使用浏览器当前 cookie,也就是 `credentials: "include"`。
|
|
||||||
|
|
||||||
## 星图 ID 来源
|
|
||||||
|
|
||||||
`o_author_id` 需要从 `search_for_author_square` 接口返回值中获取:
|
|
||||||
|
|
||||||
```text
|
|
||||||
authors[i].attribute_datas.id
|
|
||||||
```
|
|
||||||
|
|
||||||
如果同一行数据里同时存在顶层 `star_id` 和 `attribute_datas.id`,这个接口优先使用 `attribute_datas.id` 作为 `o_author_id`。
|
|
||||||
|
|
||||||
## 参数含义
|
|
||||||
|
|
||||||
### only_assign
|
|
||||||
|
|
||||||
| 值 | 含义 | 字段名前缀 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `true` | 只看指派 | `只看指派` |
|
|
||||||
| `false` | 取消“只看指派”勾选 | `不限指派` |
|
|
||||||
|
|
||||||
### flow_type
|
|
||||||
|
|
||||||
| 值 | 含义 | 字段名前缀 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `1` | 排除营销流量 | `排除营销流量` |
|
|
||||||
| `0` | 不排除营销流量 | `不排除营销流量` |
|
|
||||||
|
|
||||||
### range
|
|
||||||
|
|
||||||
| 值 | 含义 | 字段名前缀 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `2` | 近 30 天 | `近30天` |
|
|
||||||
| `3` | 近 90 天 | `近90天` |
|
|
||||||
|
|
||||||
### type
|
|
||||||
|
|
||||||
| 值 | 含义 | 字段名前缀 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `1` | 个人视频 | `个人视频` |
|
|
||||||
| `2` | 星图视频 | `星图视频` |
|
|
||||||
|
|
||||||
## 多组参数导出
|
|
||||||
|
|
||||||
第一版需要支持多组参数组合。
|
|
||||||
|
|
||||||
参数组合需要区分“个人视频”和“星图视频”两类处理:
|
|
||||||
|
|
||||||
- `type=1` 个人视频:`only_assign=false`、`flow_type=0` 固定,只允许调整 `range`。
|
|
||||||
- `type=2` 星图视频:需要支持多组参数组合,因为 `only_assign`、`flow_type`、`range` 的不同设置会导致接口返回的数据不同。
|
|
||||||
|
|
||||||
个人视频固定参数:
|
|
||||||
|
|
||||||
```text
|
|
||||||
type=1
|
|
||||||
flow_type=0
|
|
||||||
only_assign=false
|
|
||||||
```
|
|
||||||
|
|
||||||
个人视频可变参数:
|
|
||||||
|
|
||||||
```text
|
|
||||||
range=2 或 range=3
|
|
||||||
```
|
|
||||||
|
|
||||||
因为个人视频里 `only_assign=false` 和 `flow_type=0` 是固定参数,所以它们不写入字段名前缀。个人视频字段只需要体现视频类型和时间范围,例如:
|
|
||||||
|
|
||||||
```text
|
|
||||||
个人视频_近30天_完播率
|
|
||||||
个人视频_近90天_完播率
|
|
||||||
```
|
|
||||||
|
|
||||||
星图视频可以配置多组参数。每一组参数都会调用一次 `get_author_spread_info`,并为这一组参数生成 7 个导出字段。
|
|
||||||
|
|
||||||
例如某一组参数是:
|
|
||||||
|
|
||||||
```text
|
|
||||||
only_assign=true
|
|
||||||
flow_type=1
|
|
||||||
type=2
|
|
||||||
range=2
|
|
||||||
```
|
|
||||||
|
|
||||||
那么这一组会生成:
|
|
||||||
|
|
||||||
- `只看指派_排除营销流量_星图视频_近30天_完播率`
|
|
||||||
- `只看指派_排除营销流量_星图视频_近30天_播放量中位数`
|
|
||||||
- `只看指派_排除营销流量_星图视频_近30天_互动率`
|
|
||||||
- `只看指派_排除营销流量_星图视频_近30天_作品平均时长`
|
|
||||||
- `只看指派_排除营销流量_星图视频_近30天_作品平均评论数`
|
|
||||||
- `只看指派_排除营销流量_星图视频_近30天_作品平均点赞数`
|
|
||||||
- `只看指派_排除营销流量_星图视频_近30天_作品平均转发数`
|
|
||||||
|
|
||||||
字段名规则固定为:
|
|
||||||
|
|
||||||
```text
|
|
||||||
<会变化的参数文案>_<视频类型文案>_<时间范围文案>_<指标名>
|
|
||||||
```
|
|
||||||
|
|
||||||
对星图视频来说,`only_assign`、`flow_type`、`range` 都可能变化,所以字段名要保留这些参数。对个人视频来说,只有 `range` 变化,所以字段名不需要写 `不限指派` 和 `不排除营销流量`。
|
|
||||||
|
|
||||||
这里必须保留会变化参数的前缀,不能把不同参数组合下的同名指标合并。例如下面两个字段都叫“完播率”,但数据含义不同,必须作为两个独立字段导出:
|
|
||||||
|
|
||||||
```text
|
|
||||||
只看指派_排除营销流量_星图视频_近30天_完播率
|
|
||||||
不限指派_不排除营销流量_星图视频_近30天_完播率
|
|
||||||
```
|
|
||||||
|
|
||||||
## 需要导出的指标
|
|
||||||
|
|
||||||
每一组参数都要导出下面 7 个指标:
|
|
||||||
|
|
||||||
| 导出字段指标名 | 接口响应字段 | 示例值 | 说明 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 完播率 | `play_over_rate.value` | `2824` | 按万分比理解,导出时建议显示为 `28.24%` |
|
|
||||||
| 播放量中位数 | `play_mid`,兜底 `item_rate.play_mid.value` | `10913233` | 播放量中位数 |
|
|
||||||
| 互动率 | `interact_rate.value` | `402` | 按万分比理解,导出时建议显示为 `4.02%` |
|
|
||||||
| 作品平均时长 | `avg_duration` | `5600` | 按百分之一秒理解,导出时显示为秒,例如 `56` |
|
|
||||||
| 作品平均评论数 | `comment_avg` | `7502` | 平均评论数 |
|
|
||||||
| 作品平均点赞数 | `like_avg` | `494458` | 平均点赞数 |
|
|
||||||
| 作品平均转发数 | `share_avg` | `188267` | 平均转发数 |
|
|
||||||
|
|
||||||
示例响应:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"avg_duration": "5600",
|
|
||||||
"comment_avg": "7502",
|
|
||||||
"interact_rate": {
|
|
||||||
"overtake": 5312,
|
|
||||||
"value": 402
|
|
||||||
},
|
|
||||||
"item_rate": {
|
|
||||||
"play_mid": {
|
|
||||||
"label": "",
|
|
||||||
"overtake": 10000,
|
|
||||||
"value": 10913233
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"like_avg": "494458",
|
|
||||||
"play_mid": "10913233",
|
|
||||||
"play_over_rate": {
|
|
||||||
"overtake": 9584,
|
|
||||||
"value": 2824
|
|
||||||
},
|
|
||||||
"share_avg": "188267"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 导出流程
|
|
||||||
|
|
||||||
1. 当前插件仍然先从星图达人搜索页收集达人列表。
|
|
||||||
2. 从 `search_for_author_square` 的 `authors[i].attribute_datas.id` 取出每个达人的星图 ID。
|
|
||||||
3. 用户导出 CSV 时,先按现有逻辑确定导出范围,例如当前页、前 5 页、前 10 页、全部或自定义页数。
|
|
||||||
4. 对导出范围内的每个达人,先按个人视频参数调用 `get_author_spread_info`:`type=1`、`flow_type=0`、`only_assign=false` 固定,`range` 按配置取值。
|
|
||||||
5. 如果配置了星图视频参数组合,再按每一组星图视频参数分别调用 `get_author_spread_info`。
|
|
||||||
6. 把每次接口返回值解析成 7 个指标。
|
|
||||||
7. CSV 保留原有字段顺序,在现有字段后追加这些带参数前缀的新字段。
|
|
||||||
|
|
||||||
## 失败处理
|
|
||||||
|
|
||||||
- 如果某个达人没有 `attribute_datas.id`,这一行的视频传播指标留空。
|
|
||||||
- 如果某个参数组合请求失败,这一组参数对应的 7 个字段留空。
|
|
||||||
- 如果接口响应结构异常,这一组参数对应的 7 个字段留空。
|
|
||||||
- 某个达人失败不能影响其他达人导出。
|
|
||||||
- 某组参数失败不能影响同一个达人的其他参数组导出。
|
|
||||||
|
|
||||||
## 性能要求
|
|
||||||
|
|
||||||
这个功能会产生比较多接口请求:
|
|
||||||
|
|
||||||
```text
|
|
||||||
请求数 = 导出的达人数量 * 参数组合数量
|
|
||||||
```
|
|
||||||
|
|
||||||
所以实现时需要:
|
|
||||||
|
|
||||||
- 做并发限制,避免一次性打太多请求。
|
|
||||||
- 保持最终 CSV 行顺序和原导出顺序一致。
|
|
||||||
- 给每个请求设置超时时间。
|
|
||||||
- 第一版不做激进重试,避免接口压力过大。
|
|
||||||
|
|
||||||
## 测试要求
|
|
||||||
|
|
||||||
需要补充测试覆盖:
|
|
||||||
|
|
||||||
- `get_author_spread_info` URL 参数构造是否正确。
|
|
||||||
- `type=1` 生成 `个人视频` 前缀。
|
|
||||||
- `type=2` 生成 `星图视频` 前缀。
|
|
||||||
- 个人视频是否固定使用:`type=1`、`flow_type=0`、`only_assign=false`。
|
|
||||||
- 个人视频是否支持切换 `range=2` 和 `range=3`。
|
|
||||||
- 个人视频字段名前缀是否不包含固定参数 `不限指派` 和 `不排除营销流量`。
|
|
||||||
- 星图视频是否支持多组参数组合。
|
|
||||||
- `only_assign`、`flow_type`、`range` 前缀是否正确。
|
|
||||||
- 是否从 `attribute_datas.id` 读取 `o_author_id`。
|
|
||||||
- 多组参数是否分别生成 7 个字段。
|
|
||||||
- 响应字段是否正确映射到 7 个导出指标。
|
|
||||||
- 接口失败时是否导出空字段。
|
|
||||||
- 多个达人并发请求完成顺序不一致时,最终 CSV 行顺序是否保持不变。
|
|
||||||
|
|
||||||
## 暂不做的事情
|
|
||||||
|
|
||||||
- 暂不新增页面上的参数配置 UI。
|
|
||||||
- 暂不改变星图搜索页原本的筛选条件。
|
|
||||||
- 暂不改变现有后端指标字段。
|
|
||||||
- 暂不改变批次提交 payload。
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
# 星图达人传播指标阈值筛选需求文档
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
在导出 CSV 或提交批次之前,允许用户按一组视频传播数据参数和指标阈值对达人做二次筛选。
|
|
||||||
|
|
||||||
只有满足筛选条件的达人,才进入最终导出或提交批次。
|
|
||||||
|
|
||||||
## 筛选维度
|
|
||||||
|
|
||||||
筛选维度对应 `get_author_spread_info` 的请求参数:
|
|
||||||
|
|
||||||
| UI 维度 | 接口参数 | 可选值 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 视频类别 | `type` | 个人视频 / 星图视频 |
|
|
||||||
| 是否指派 | `only_assign` | 只看指派 / 不限指派 |
|
|
||||||
| 是否排除营销流量 | `flow_type` | 排除营销流量 / 不排除营销流量 |
|
|
||||||
| 数据范围 | `range` | 近30天 / 近90天 |
|
|
||||||
|
|
||||||
个人视频的参数约束:
|
|
||||||
|
|
||||||
- `type=1`
|
|
||||||
- `only_assign=false`
|
|
||||||
- `flow_type=0`
|
|
||||||
- `range` 可选近30天或近90天
|
|
||||||
|
|
||||||
星图视频的参数约束:
|
|
||||||
|
|
||||||
- `type=2`
|
|
||||||
- `only_assign` 可选
|
|
||||||
- `flow_type` 可选
|
|
||||||
- `range` 可选近30天或近90天
|
|
||||||
|
|
||||||
## 指标阈值
|
|
||||||
|
|
||||||
支持下面 7 个指标阈值:
|
|
||||||
|
|
||||||
- 完播率 >=
|
|
||||||
- 播放量中位数 >=
|
|
||||||
- 互动率 >=
|
|
||||||
- 作品平均时长 >=
|
|
||||||
- 作品平均评论数 >=
|
|
||||||
- 作品平均点赞数 >=
|
|
||||||
- 作品平均转发数 >=
|
|
||||||
|
|
||||||
规则:
|
|
||||||
|
|
||||||
- 没填的阈值不参与筛选。
|
|
||||||
- 填了多个阈值时,必须全部满足才保留达人。
|
|
||||||
- 完播率和互动率使用百分数显示值,例如填 `30` 表示 `30%`。
|
|
||||||
- 作品平均时长使用秒,例如填 `56` 表示 `56秒`。
|
|
||||||
- 播放量、评论、点赞、转发使用普通数字。
|
|
||||||
- 如果某个达人在所选参数组合下接口请求失败或缺少被启用的指标,则视为不满足筛选。
|
|
||||||
|
|
||||||
## 生效范围
|
|
||||||
|
|
||||||
阈值筛选同时作用于:
|
|
||||||
|
|
||||||
- 导出 CSV
|
|
||||||
- 提交批次
|
|
||||||
|
|
||||||
处理顺序:
|
|
||||||
|
|
||||||
1. 先按现有导出范围收集达人,例如当前页、前5页、前10页、全部或自定义页数。
|
|
||||||
2. 如果用户没有填写任何阈值,保持现有导出/提交行为。
|
|
||||||
3. 如果用户填写了阈值,对收集到的每个达人按当前筛选维度调用一次 `get_author_spread_info`。
|
|
||||||
4. 将接口响应映射为显示值。
|
|
||||||
5. 用已填写的阈值过滤达人。
|
|
||||||
6. 过滤后的达人进入导出 CSV 或提交批次。
|
|
||||||
|
|
||||||
## UI 设计
|
|
||||||
|
|
||||||
在现有插件操作区中增加一组紧凑控件:
|
|
||||||
|
|
||||||
- 视频类别下拉框
|
|
||||||
- 指派下拉框
|
|
||||||
- 营销流量下拉框
|
|
||||||
- 数据范围下拉框
|
|
||||||
- 7 个数字输入框
|
|
||||||
|
|
||||||
当视频类别选择“个人视频”时:
|
|
||||||
|
|
||||||
- 指派固定为“不限指派”
|
|
||||||
- 营销流量固定为“不排除营销流量”
|
|
||||||
- 对应控件禁用
|
|
||||||
|
|
||||||
## 失败处理
|
|
||||||
|
|
||||||
- 单个达人筛选请求失败:该达人不满足筛选。
|
|
||||||
- 全部达人都不满足:导出空 CSV 表头;提交批次时按现有空记录处理。
|
|
||||||
- 阈值输入非法:阻止导出/提交,并提示用户修正。
|
|
||||||
|
|
||||||
## 测试要求
|
|
||||||
|
|
||||||
- 读取 toolbar 中的筛选参数和阈值。
|
|
||||||
- 个人视频禁用指派和营销流量控件。
|
|
||||||
- 空阈值时不触发二次筛选。
|
|
||||||
- 有阈值时按所选参数调用 `get_author_spread_info`。
|
|
||||||
- 百分比阈值按显示值比较。
|
|
||||||
- 多个阈值按 AND 关系过滤。
|
|
||||||
- 导出 CSV 和提交批次都应用二次筛选。
|
|
||||||
@ -111,22 +111,6 @@ git pull
|
|||||||
- 然后重新点击 **"加载已解压的扩展程序"**
|
- 然后重新点击 **"加载已解压的扩展程序"**
|
||||||
- 再次选择 `dist` 文件夹
|
- 再次选择 `dist` 文件夹
|
||||||
|
|
||||||
## ♻️ 老用户一次性升级说明
|
|
||||||
|
|
||||||
如果你之前已经装过比较早的旧版本,而且插件弹窗里一直显示:
|
|
||||||
|
|
||||||
- `暂时无法检查更新`
|
|
||||||
|
|
||||||
那通常说明你本地还是旧的更新机制,建议先手动完成一次升级:
|
|
||||||
|
|
||||||
1. 先获取我发出的最新压缩包
|
|
||||||
2. 解压后得到新的 `dist` 文件夹
|
|
||||||
3. 打开 `chrome://extensions`
|
|
||||||
4. 删除旧插件,或重新执行一次 **"加载已解压的扩展程序"**
|
|
||||||
5. 重新选择新的 `dist` 文件夹
|
|
||||||
|
|
||||||
完成这一次后,后续再更新时,就可以继续沿用统一的 `dist` 目录方式。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ❓ 常见问题
|
## ❓ 常见问题
|
||||||
@ -140,9 +124,6 @@ A: 请确认下载的是最新版本,可以重新执行 `git pull` 并重新
|
|||||||
**Q: 加载后扩展 ID 不对?**
|
**Q: 加载后扩展 ID 不对?**
|
||||||
A: 请检查是否选择了 `dist` 文件夹,而不是外层文件夹。
|
A: 请检查是否选择了 `dist` 文件夹,而不是外层文件夹。
|
||||||
|
|
||||||
**Q: 我已经在用这个插件了,还需要再用压缩包更新一次吗?**
|
|
||||||
A: 不一定。只有那些当前弹窗仍然只显示 `暂时无法检查更新` 的旧用户,才建议手动用最新压缩包重新装一次 `dist` 来完成过桥升级。已经能正常发现新版本的同事,继续按普通更新流程走即可。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📞 需要帮助?
|
## 📞 需要帮助?
|
||||||
|
|||||||
@ -140,24 +140,6 @@ https://xingtu.cn/ad/creator/market
|
|||||||
- 然后重新点击 **"加载已解压的扩展程序"**
|
- 然后重新点击 **"加载已解压的扩展程序"**
|
||||||
- 再次选择新解压出来的 `dist` 文件夹
|
- 再次选择新解压出来的 `dist` 文件夹
|
||||||
|
|
||||||
## ♻️ 老用户一次性升级说明
|
|
||||||
|
|
||||||
如果你之前安装的是较早的旧版本,并且插件弹窗里一直显示:
|
|
||||||
|
|
||||||
- `暂时无法检查更新`
|
|
||||||
|
|
||||||
那通常说明你本地还在使用“旧更新机制”的插件包。
|
|
||||||
|
|
||||||
这种情况下,需要**手动用一次最新压缩包升级**:
|
|
||||||
|
|
||||||
1. 下载最新的 `star-chart-search-enhancer-internal.zip`
|
|
||||||
2. 解压后得到新的 `dist` 文件夹
|
|
||||||
3. 打开 `chrome://extensions`
|
|
||||||
4. 删除旧插件,或重新点击 **"加载已解压的扩展程序"**
|
|
||||||
5. 重新选择新的 `dist` 文件夹
|
|
||||||
|
|
||||||
完成这一次“过桥升级”后,后面再看到新版本时,就可以继续按统一的 `dist` 更新方式操作。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ❓ 常见问题
|
## ❓ 常见问题
|
||||||
@ -174,9 +156,6 @@ A: 检查浏览器的下载列表,文件可能已经下好了
|
|||||||
### Q: 不小心把文件夹删了?
|
### Q: 不小心把文件夹删了?
|
||||||
A: 重新解压压缩包,然后到 `chrome://extensions` 点"重新加载"
|
A: 重新解压压缩包,然后到 `chrome://extensions` 点"重新加载"
|
||||||
|
|
||||||
### Q: 我已经在用插件了,还需要再用一次压缩包更新吗?
|
|
||||||
A: 如果你当前弹窗能正常显示 `发现新版本`,就不需要额外做特殊处理,按普通更新步骤走即可。如果弹窗一直只显示 `暂时无法检查更新`,建议手动用最新压缩包重新安装一次 `dist`,完成一次性升级。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ 每日使用 checklist
|
## ✅ 每日使用 checklist
|
||||||
|
|||||||
730
docs/项目流程说明文档.md
730
docs/项目流程说明文档.md
@ -1,730 +0,0 @@
|
|||||||
# 项目流程说明文档
|
|
||||||
|
|
||||||
## 1. 项目用途
|
|
||||||
|
|
||||||
本项目是公司内部使用的 Chrome 插件,用于增强巨量星图达人市场页面的达人筛选、数据导出和批次提交效率。
|
|
||||||
|
|
||||||
它解决的业务问题是:使用者在星图达人市场中筛选达人后,可以直接在页面上补充查看看后搜率、秒思后台指标等数据,并把选中的达人导出为 CSV,或提交为后续业务处理批次。
|
|
||||||
|
|
||||||
项目输入包括:
|
|
||||||
|
|
||||||
- 巨量星图达人市场页面中的达人列表、筛选条件和分页结果;
|
|
||||||
- 使用者在页面上勾选的达人;
|
|
||||||
- 使用者粘贴的达人星图 ID;
|
|
||||||
- 使用者填写的批次名称;
|
|
||||||
- 使用者选择的导出字段和传播指标筛选阈值;
|
|
||||||
- 当前插件登录用户的 Logto 身份和访问令牌。
|
|
||||||
|
|
||||||
项目处理过程包括:
|
|
||||||
|
|
||||||
- 在星图达人市场页面挂载插件工具栏;
|
|
||||||
- 读取当前页面或星图列表接口返回的达人数据;
|
|
||||||
- 根据勾选范围、分页范围、阈值筛选规则确定最终达人集合;
|
|
||||||
- 调用星图接口补充看后搜率、画像、商业能力、传播指标等信息;
|
|
||||||
- 调用公司后端接口补充秒思 api 指标;
|
|
||||||
- 生成 CSV 文件,或组装批次 payload 提交到后端。
|
|
||||||
|
|
||||||
项目输出包括:
|
|
||||||
|
|
||||||
- 页面上新增的数据列和状态提示;
|
|
||||||
- 下载到本地的 CSV 文件;
|
|
||||||
- 提交到后端的达人批次;
|
|
||||||
- 插件弹窗中的登录状态和更新状态。
|
|
||||||
|
|
||||||
项目依赖的外部平台、数据源或服务包括:
|
|
||||||
|
|
||||||
- 巨量星图网页和星图接口;
|
|
||||||
- 公司 Logto 登录系统;
|
|
||||||
- 公司 talent-search 后端服务;
|
|
||||||
- 本地或内网批次提交后端;
|
|
||||||
- COS 上的插件更新清单和安装包。
|
|
||||||
|
|
||||||
主要使用者是 AIGC 部门或相关业务同事。通常在以下场景使用:
|
|
||||||
|
|
||||||
- 在星图市场中筛选达人后,需要快速导出达人数据;
|
|
||||||
- 已知一批星图 ID,需要批量补齐画像、效果预估和内容指标;
|
|
||||||
- 需要把一批达人提交给后续业务系统继续处理;
|
|
||||||
- 需要检查或更新内部插件版本。
|
|
||||||
|
|
||||||
## 2. 整体流程总览
|
|
||||||
|
|
||||||
完整主流程按真实使用顺序如下:
|
|
||||||
|
|
||||||
1. 安装或更新插件;
|
|
||||||
2. 登录插件;
|
|
||||||
3. 打开巨量星图达人市场页面;
|
|
||||||
4. 插件读取登录状态并挂载工具栏;
|
|
||||||
5. 插件读取当前星图达人列表,并补充页面展示指标;
|
|
||||||
6. 使用者选择达人、字段、传播指标筛选条件或输入星图 ID;
|
|
||||||
7. 使用者触发导出或提交批次;
|
|
||||||
8. 插件收集达人数据,按规则过滤、去重、补充字段;
|
|
||||||
9. 插件调用星图接口和公司后端接口补充数据;
|
|
||||||
10. 插件下载 CSV,或把批次提交给后端;
|
|
||||||
11. 使用者通过状态提示、下载文件或后端批次结果确认任务完成。
|
|
||||||
|
|
||||||
### 2.1 安装或更新插件
|
|
||||||
|
|
||||||
- 触发者:使用者或管理员。
|
|
||||||
- 输入:内部 ZIP 安装包,或插件弹窗中发现的新版本安装包。
|
|
||||||
- 处理:解压 ZIP,在 Chrome 扩展页加载 `dist` 文件夹;更新时下载新版 ZIP 后人工重新加载插件。
|
|
||||||
- 输出:Chrome 中安装好的 `Star Chart Search Enhancer` 插件。
|
|
||||||
- 下一步:登录插件。
|
|
||||||
- 人工操作:需要人工解压、加载或重新加载插件。
|
|
||||||
- 条件分支:如果旧版本无法检查更新,需要做一次手动过桥升级。
|
|
||||||
|
|
||||||
### 2.2 登录插件
|
|
||||||
|
|
||||||
- 触发者:使用者点击插件弹窗中的登录按钮。
|
|
||||||
- 输入:公司账号登录态。
|
|
||||||
- 处理:通过 Logto 完成 Chrome 扩展登录,并获取访问后端资源的 token。
|
|
||||||
- 输出:插件弹窗显示已登录状态,内容脚本后续可以挂载业务工具栏。
|
|
||||||
- 下一步:打开星图达人市场页面。
|
|
||||||
- 人工操作:需要使用者完成登录。
|
|
||||||
- 条件分支:未登录或登录过期时,星图页面不会进入导出/提交流程,只显示登录提示。
|
|
||||||
|
|
||||||
### 2.3 打开星图达人市场页面
|
|
||||||
|
|
||||||
- 触发者:使用者访问星图市场页面。
|
|
||||||
- 输入:星图网页登录态、当前页面筛选条件、星图市场列表。
|
|
||||||
- 处理:插件仅在 `xingtu.cn` 域名下的达人市场页面生效;进入页面后先安装页面桥接逻辑,再检查插件登录状态。
|
|
||||||
- 输出:页面上出现插件工具栏和增强列。
|
|
||||||
- 下一步:读取达人列表并补充数据。
|
|
||||||
- 人工操作:使用者需要在星图网页中完成筛选、搜索、翻页或勾选。
|
|
||||||
- 条件分支:如果页面不是星图达人市场页面,插件不启动主流程。
|
|
||||||
|
|
||||||
### 2.4 页面增强和数据补充
|
|
||||||
|
|
||||||
- 触发者:星图页面加载、翻页、列表变化或插件同步周期。
|
|
||||||
- 输入:页面可见达人行、星图列表接口返回值、当前登录用户 token。
|
|
||||||
- 处理:插件读取达人 ID、名称、地区、报价等基础数据,补充看后搜率列和秒思指标列。
|
|
||||||
- 输出:页面上显示加载中、成功、失败或暂无数据等状态。
|
|
||||||
- 下一步:使用者选择导出、按 ID 导出或提交批次。
|
|
||||||
- 人工操作:无。
|
|
||||||
- 条件分支:如果某个指标加载失败,只影响该达人对应指标,不影响页面整体使用。
|
|
||||||
|
|
||||||
### 2.5 导出选中达人数据
|
|
||||||
|
|
||||||
- 触发者:使用者点击 `导出选中达人数据`。
|
|
||||||
- 输入:当前勾选达人、当前导出范围、字段选择配置、传播指标筛选条件。
|
|
||||||
- 处理:必须先勾选达人;插件收集导出范围内的达人,只保留该范围内已勾选的达人,然后补充内容数据、效果预估、画像、秒思指标等字段。
|
|
||||||
- 输出:CSV 文件下载到浏览器默认下载目录。
|
|
||||||
- 下一步:使用者检查 CSV 内容。
|
|
||||||
- 人工操作:需要使用者勾选达人并点击按钮。
|
|
||||||
- 条件分支:如果当前导出范围内没有选中的达人,则不下载 CSV 并提示。
|
|
||||||
|
|
||||||
### 2.6 按星图 ID 导出
|
|
||||||
|
|
||||||
- 触发者:使用者点击 `按星图ID导出`。
|
|
||||||
- 输入:弹窗中粘贴的达人星图 ID。
|
|
||||||
- 处理:插件校验 ID 格式、去重、忽略非法 token,然后逐个 ID 请求基础信息、看后搜率、传播指标、画像、商业能力和后端秒思指标。
|
|
||||||
- 输出:CSV 文件下载到浏览器默认下载目录。
|
|
||||||
- 下一步:使用者检查 CSV 中每个 ID 的导出状态和失败原因。
|
|
||||||
- 人工操作:需要使用者粘贴 ID 并确认。
|
|
||||||
- 条件分支:如果没有有效 ID,不执行导出并提示。
|
|
||||||
|
|
||||||
### 2.7 提交批次
|
|
||||||
|
|
||||||
- 触发者:使用者点击 `提交批次`。
|
|
||||||
- 输入:当前范围或已勾选达人、批次名称、登录用户信息。
|
|
||||||
- 处理:插件先要求输入批次名称,再收集达人数据,应用传播指标阈值筛选和选中规则,检查登录状态,组装批次 payload,提交到后端。
|
|
||||||
- 输出:后端生成批次;页面显示 `批次提交成功` 或失败原因。
|
|
||||||
- 下一步:在后端系统中继续处理批次。
|
|
||||||
- 人工操作:需要使用者输入批次名称。
|
|
||||||
- 条件分支:未登录、批次名为空、后端拒绝或接口失败都会导致本次提交失败。
|
|
||||||
|
|
||||||
## 3. 详细流程说明
|
|
||||||
|
|
||||||
### 3.1 插件安装与更新
|
|
||||||
|
|
||||||
- 步骤目的:让使用者在 Chrome 中获得可运行的内部插件。
|
|
||||||
- 输入内容:内部发布 ZIP、安装说明 PDF、Chrome 浏览器。
|
|
||||||
- 处理规则:
|
|
||||||
- 首次安装需要解压 ZIP;
|
|
||||||
- Chrome 加载的是解压后的 `dist` 文件夹;
|
|
||||||
- 更新时仍然需要人工下载、解压并重新加载;
|
|
||||||
- 扩展 ID 应为 `pkjopdibdnomhogjheclhnknmejccffg`。
|
|
||||||
- 输出结果:Chrome 扩展列表中出现正确插件。
|
|
||||||
- 外部依赖:Chrome 扩展能力;COS 更新文件。
|
|
||||||
- 失败后如何处理:安装失败不会影响外部数据;使用者无法进入后续流程。
|
|
||||||
- 是否影响后续步骤:影响。未安装或安装版本不正确时,后续导出和提交不可用。
|
|
||||||
|
|
||||||
### 3.2 插件登录
|
|
||||||
|
|
||||||
- 步骤目的:获得访问公司后端和受保护接口所需的用户身份。
|
|
||||||
- 输入内容:公司登录账号、Logto 登录配置。
|
|
||||||
- 处理规则:
|
|
||||||
- 登录通过 Chrome identity 回调完成;
|
|
||||||
- 插件读取用户 `sub`、用户名、资源地址和 scope;
|
|
||||||
- 内容脚本进入星图页面时会先读取登录状态。
|
|
||||||
- 输出结果:已登录状态、可用 access token。
|
|
||||||
- 外部依赖:Logto 登录系统。
|
|
||||||
- 失败后如何处理:星图页面显示登录提示;不挂载业务工具栏。
|
|
||||||
- 是否影响后续步骤:影响。批次提交和后端指标查询都依赖 token。
|
|
||||||
|
|
||||||
### 3.3 星图市场页面启动
|
|
||||||
|
|
||||||
- 步骤目的:只在正确页面启用插件能力。
|
|
||||||
- 输入内容:当前浏览器 URL、页面 DOM、星图页面列表请求。
|
|
||||||
- 处理规则:
|
|
||||||
- 只匹配巨量星图达人市场页面;
|
|
||||||
- 进入页面后先安装桥接逻辑,用于捕获星图市场列表请求和页面列表数据;
|
|
||||||
- 未登录时不进入业务控制流程;
|
|
||||||
- 已登录时挂载工具栏和新增列。
|
|
||||||
- 输出结果:插件工具栏、选择框、增强数据列。
|
|
||||||
- 外部依赖:星图网页结构和浏览器内容脚本能力。
|
|
||||||
- 失败后如何处理:如果页面结构变化导致无法挂载,相关功能不可用;未确认是否有统一错误上报。
|
|
||||||
- 是否影响后续步骤:影响。工具栏未挂载时无法导出或提交。
|
|
||||||
|
|
||||||
### 3.4 读取达人列表
|
|
||||||
|
|
||||||
- 步骤目的:确定当前页面或导出范围内有哪些达人。
|
|
||||||
- 输入内容:星图页面列表行、星图列表接口返回值、当前分页状态。
|
|
||||||
- 处理规则:
|
|
||||||
- 优先从星图市场列表接口返回中读取达人;
|
|
||||||
- 关键字段包括达人 ID、达人名称、星图内部传播接口 ID、核心用户 ID、地区、报价和页面可导出字段;
|
|
||||||
- 如果没有捕获到接口请求,则从页面 DOM 读取可见行;
|
|
||||||
- 页面翻页导出时等待页面稳定后再读取;
|
|
||||||
- 同一个达人重复出现时按达人 ID 合并。
|
|
||||||
- 输出结果:标准化后的达人记录集合。
|
|
||||||
- 外部依赖:星图市场列表接口和页面 DOM。
|
|
||||||
- 失败后如何处理:
|
|
||||||
- 单页加载超时会终止本次范围导出;
|
|
||||||
- 单条达人缺少 ID 或名称会被跳过;
|
|
||||||
- 捕获接口失败时退回页面翻页读取。
|
|
||||||
- 是否影响后续步骤:影响。没有达人记录时,导出或提交结果为空或失败。
|
|
||||||
|
|
||||||
### 3.5 页面指标补充
|
|
||||||
|
|
||||||
- 步骤目的:在列表页直接显示补充指标,方便筛选和排序。
|
|
||||||
- 输入内容:当前页面达人 ID。
|
|
||||||
- 处理规则:
|
|
||||||
- 看后搜率优先使用星图列表中已有值;
|
|
||||||
- 如果列表值不完整,再调用星图看后搜率相关接口;
|
|
||||||
- 秒思指标按当前页达人 ID 批量查询后端;
|
|
||||||
- 已成功或已判定缺失的后端指标在当前页面会话中不重复查询;
|
|
||||||
- 指标列支持页面内排序。
|
|
||||||
- 输出结果:页面增强列显示成功值、加载中、加载失败或暂无数据。
|
|
||||||
- 外部依赖:星图接口、公司后端指标接口。
|
|
||||||
- 失败后如何处理:单个达人指标失败只显示失败,不阻塞其他达人。
|
|
||||||
- 是否影响后续步骤:部分影响。导出时会复用已缓存指标,缺失时可能再次补充。
|
|
||||||
|
|
||||||
### 3.6 导出选中达人数据
|
|
||||||
|
|
||||||
- 步骤目的:把使用者选定的达人数据导出为 CSV。
|
|
||||||
- 输入内容:已勾选达人、当前导出范围、字段选择配置、传播指标筛选条件。
|
|
||||||
- 处理规则:
|
|
||||||
- 必须先勾选达人;
|
|
||||||
- 先按导出范围收集达人;
|
|
||||||
- 再严格保留当前导出范围内已勾选的达人;
|
|
||||||
- 对每个达人补充画像、商业能力、传播指标、看后搜率、秒思指标;
|
|
||||||
- 字段选择只控制可选字段,基础字段、导出状态和失败原因等固定保留;
|
|
||||||
- 如果全部画像请求失败,则不下载 CSV 并提示画像导出失败;
|
|
||||||
- 单个达人部分接口失败时,CSV 保留该行,并写入导出状态和失败原因。
|
|
||||||
- 输出结果:CSV 文件。
|
|
||||||
- 外部依赖:星图画像接口、商业能力接口、传播指标接口、公司后端指标接口、浏览器下载能力。
|
|
||||||
- 失败后如何处理:
|
|
||||||
- 没有勾选达人:提示并停止;
|
|
||||||
- 当前范围内无选中达人:提示并停止;
|
|
||||||
- 单个达人部分失败:记录为部分成功或失败;
|
|
||||||
- 全部画像失败:不下载 CSV。
|
|
||||||
- 是否影响后续步骤:不影响外部系统写入;只影响本次下载结果。
|
|
||||||
|
|
||||||
### 3.7 按星图 ID 导出
|
|
||||||
|
|
||||||
- 步骤目的:在不依赖当前星图列表勾选的情况下,批量导出指定 ID 的达人数据。
|
|
||||||
- 输入内容:使用者粘贴的星图 ID 文本。
|
|
||||||
- 处理规则:
|
|
||||||
- 支持用空格、换行、英文逗号、中文逗号、英文分号、中文分号分隔;
|
|
||||||
- 只接受 16 到 20 位纯数字;
|
|
||||||
- 重复 ID 会去重;
|
|
||||||
- 非法 token 会计入提示,但不会进入导出;
|
|
||||||
- 对有效 ID 逐个补齐基础信息、看后搜率、传播指标、画像、商业能力和后端秒思指标;
|
|
||||||
- 每个 ID 生成一行 CSV,并标记成功、部分成功或失败。
|
|
||||||
- 输出结果:按 ID 导出的 CSV 文件。
|
|
||||||
- 外部依赖:星图基础信息接口、星图指标接口、公司后端指标接口、浏览器下载能力。
|
|
||||||
- 失败后如何处理:
|
|
||||||
- 没有有效 ID:提示并停止;
|
|
||||||
- 单个 ID 的部分接口失败:保留该行并写失败原因;
|
|
||||||
- 整体流程异常:提示按 ID 导出失败。
|
|
||||||
- 是否影响后续步骤:不写入外部系统,不影响批次。
|
|
||||||
|
|
||||||
### 3.8 传播指标阈值筛选
|
|
||||||
|
|
||||||
- 步骤目的:在导出或提交前按内容传播表现过滤达人。
|
|
||||||
- 输入内容:视频类别、是否只看指派、是否排除营销流量、时间范围、七个指标阈值。
|
|
||||||
- 处理规则:
|
|
||||||
- 没有填写任何阈值时,不启用该筛选;
|
|
||||||
- 填写多个阈值时必须全部满足;
|
|
||||||
- 个人视频固定为不限指派、不排除营销流量;
|
|
||||||
- 星图视频可选择只看指派、不限指派、排除营销流量或不排除营销流量;
|
|
||||||
- 完播率和互动率按显示百分数比较,例如 `30` 表示 `30%`;
|
|
||||||
- 平均时长按秒比较;
|
|
||||||
- 播放量、评论、点赞、转发按普通数字比较;
|
|
||||||
- 请求失败或缺少被启用指标的达人视为不满足筛选。
|
|
||||||
- 输出结果:过滤后的达人集合。
|
|
||||||
- 外部依赖:星图传播指标接口。
|
|
||||||
- 失败后如何处理:
|
|
||||||
- 阈值非法:阻止导出或提交并提示;
|
|
||||||
- 单个达人筛选请求失败:跳过该达人;
|
|
||||||
- 全部不满足:导出时可能生成只有表头的 CSV;提交批次时会按空记录继续组装并提交,后端是否接受未确认。
|
|
||||||
- 是否影响后续步骤:影响。过滤后的结果才进入 CSV 或批次 payload。
|
|
||||||
|
|
||||||
### 3.9 批次提交
|
|
||||||
|
|
||||||
- 步骤目的:把达人集合提交给后续业务系统。
|
|
||||||
- 输入内容:导出范围内达人、已选达人、传播指标筛选结果、批次名称、登录用户信息。
|
|
||||||
- 处理规则:
|
|
||||||
- 点击后先输入批次名称;
|
|
||||||
- 取消输入则停止;
|
|
||||||
- 批次名为空则提示并停止;
|
|
||||||
- 如果存在已勾选达人,则优先提交当前范围内已勾选达人;
|
|
||||||
- 如果没有勾选达人,则提交当前范围内所有达人;
|
|
||||||
- 如果已勾选达人不在当前范围内,则回退为提交当前范围内所有达人;
|
|
||||||
- 前端不生成批次 ID,批次 ID 由后端生成;
|
|
||||||
- payload 包含登录用户 ID、创建人名称、资源地址、批次名称、创建时间和达人列表;
|
|
||||||
- 达人列表包含达人 ID、达人名称,存在核心用户 ID 时额外带上。
|
|
||||||
- 输出结果:后端批次记录;页面提示提交成功或失败。
|
|
||||||
- 外部依赖:Logto token、批次提交后端。
|
|
||||||
- 失败后如何处理:
|
|
||||||
- 未登录:提示请先登录插件;
|
|
||||||
- token 不可用:提交失败;
|
|
||||||
- 401 或 403:提交失败并返回未授权错误;
|
|
||||||
- 后端返回非成功:提交失败并显示错误;
|
|
||||||
- 网络失败:提交失败。
|
|
||||||
- 是否影响后续步骤:影响外部后端数据。是否会重复创建批次取决于后端,当前项目前端没有确认幂等机制。
|
|
||||||
|
|
||||||
### 3.10 CSV 下载
|
|
||||||
|
|
||||||
- 步骤目的:把导出结果交付给使用者。
|
|
||||||
- 输入内容:生成好的 CSV 字符串和文件名。
|
|
||||||
- 处理规则:
|
|
||||||
- 优先通过 Chrome 扩展后台下载;
|
|
||||||
- 如果扩展下载通道不可用,则使用页面中的临时下载链接;
|
|
||||||
- CSV 带 UTF-8 BOM,方便表格软件识别中文;
|
|
||||||
- 普通导出文件名使用插件名加时间戳;
|
|
||||||
- 按 ID 导出文件名包含按 ID 导出标识。
|
|
||||||
- 输出结果:浏览器下载列表中出现 CSV 文件。
|
|
||||||
- 外部依赖:Chrome downloads 能力或浏览器下载能力。
|
|
||||||
- 失败后如何处理:下载失败会提示;已生成数据不会写入外部系统。
|
|
||||||
- 是否影响后续步骤:不影响外部系统。
|
|
||||||
|
|
||||||
## 4. 接口和外部服务说明
|
|
||||||
|
|
||||||
| 接口/服务 | 用途 | 使用环节 | 核心入参 | 核心返回 | 分页 | 限流 | 并发控制 | 超时 | 重试机制 | 重试次数 | 会重试的情况 | 不会重试的情况 | 凭证/权限 | 额度/成本 | 失败处理 |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| 巨量星图网页 | 提供达人市场页面和当前筛选结果 | 页面启动、读取达人列表、人工筛选 | 星图网页登录态、页面筛选条件 | 达人列表页面 | 通过页面分页 | 未确认 | 页面翻页串行执行 | 页面翻页等待约 3 秒,页面稳定等待约 12 秒 | 无自动重试 | 0 | 无 | 页面结构异常、未登录、加载失败 | 星图账号和 cookie | 未确认 | 页面不匹配或结构异常时插件功能不可用 |
|
|
||||||
| `search_for_author_square` | 获取星图达人市场列表数据 | 后台分页导出、读取达人基础字段 | 当前星图列表请求参数、页码 | 达人列表、分页信息、基础字段 | 是 | 未确认 | 后台分页串行请求;`全部` 最多尝试 200 页 | 未单独设置 fetch 超时 | 无自动重试 | 0 | 无 | 请求失败、响应结构异常、解析失败 | 星图网页登录态和 cookie | 未确认 | 请求失败或解析失败时退回页面翻页读取 |
|
|
||||||
| `get_author_commerce_seed_base_info` | 优先获取看后搜率 | 页面增强、导出补充 | `o_author_id`、`range=90` | 商单视频/个人视频看后搜率相关字段 | 否 | 未确认 | 页面增强按当前页达人并发请求;导出补充按达人串行处理 | 8 秒 | 有备用接口回退,但不是同接口重试 | 0 | 非超时失败且未成功时转备用接口 | 超时、备用接口也失败 | 星图网页登录态和 cookie | 未确认 | 请求失败时转备用接口;超时直接记为失败 |
|
|
||||||
| `get_author_ase_info` | 备用获取看后搜率 | 页面增强、导出补充 | `author_id`、`range=30` | 看后搜率相关字段 | 否 | 未确认 | 页面增强按当前页达人并发请求;导出补充按达人串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、缺少指标 | 星图网页登录态和 cookie | 未确认 | 失败后标记该达人看后搜率失败 |
|
|
||||||
| `author_audience_distribution` | 获取观众画像 | 导出选中达人、按 ID 导出 | `o_author_id`、`platform_source=1`、`platform_channel=1`、`link_type=5` | 性别、年龄、省份、城市、兴趣、人群等分布 | 否 | 未确认 | 单个达人内画像和商业能力并发;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单项失败写入失败原因;全部画像失败时不下载选中导出 CSV |
|
|
||||||
| `get_author_fans_distribution` | 获取粉丝画像和铁粉画像 | 导出选中达人、按 ID 导出 | `o_author_id`、`platform_source=1`、`author_type=1 或 5` | 粉丝或铁粉分布 | 否 | 未确认 | 单个达人内多个画像请求串行执行;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单项失败写入失败原因 |
|
|
||||||
| `get_author_base_info` | 按 ID 导出时获取达人基础信息 | 按星图 ID 导出 | `o_author_id`、`platform_source=1`、`platform_channel=1`、`recommend=true` 等 | 达人名称等基础信息 | 否 | 未确认 | 按 ID 导出中与看后搜率并发;不同 ID 串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 单个 ID 基础信息失败,CSV 中记录失败 |
|
|
||||||
| `get_author_commerce_spread_info` | 获取商业能力和效果预估 | 导出选中达人、按 ID 导出 | `o_author_id` | 预期 CPM、预期 CPE、预期播放量、爆文率 | 否 | 未确认 | 与画像请求并发;达人之间串行处理 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败或超时 | 星图网页登录态和 cookie | 未确认 | 单项失败写入失败原因,其他数据继续 |
|
|
||||||
| `get_author_spread_info` | 获取内容传播指标,并用于阈值筛选 | 内容数据导出、阈值筛选 | `o_author_id`、`platform_source=1`、`platform_channel=1`、`type`、`flow_type`、`only_assign`、`range` | 完播率、播放量中位数、互动率、平均时长、平均评论、平均点赞、平均转发 | 否 | 未确认 | 指标补充对达人并发;单个达人内多组参数串行;筛选对达人并发 | 8 秒 | 无自动重试 | 0 | 无 | 任意失败、超时、响应缺字段 | 星图网页登录态和 cookie | 未确认 | 指标补充失败时字段留空;筛选请求失败时该达人不满足筛选 |
|
|
||||||
| talent-search 后端 `POST /api/v1/history/talents/search` | 查询秒思 api 指标 | 页面增强、CSV 导出、按 ID 导出 | Bearer token、`type=star_id`、`values`、`page=1`、`size=max(20, ID数量)` | 看后搜率、看后搜数、新增 A3、CPA3、cp_search 等 | 请求固定第一页;接口本身是否支持更多页未确认 | 未确认 | 按页面或导出集合批量请求;同一批只有一个请求 | 未确认 | 无自动重试 | 0 | 无 | token 失败、请求失败、响应结构异常 | Logto access token,当前 resource 为 talent-search | 未确认 | 页面增强中失败标记后端指标失败;导出中失败则相关字段为空 |
|
|
||||||
| 批次提交后端 `POST /api/v1/batch-status/batches` | 创建达人批次 | 提交批次 | Bearer token、批次名称、创建人、达人列表 | 成功标志和后端数据 | 否 | 未确认 | 每次点击提交只发一个请求;按钮忙碌态防止流程内重复点击 | 未确认 | 无自动重试 | 0 | 无 | 401、403、非 2xx、后端 `success` 非 true、网络失败 | Logto access token;写权限 scope 是否足够未确认 | 未确认 | 401/403 或非成功响应会终止本次提交 |
|
|
||||||
| Logto | 插件登录和获取访问 token | 登录、后端接口调用 | appId、resource、scope、Chrome redirect URL | 登录态、ID claims、access token | 否 | 未确认 | 由 Logto SDK 管理,项目内未设并发规则 | 未确认 | 项目内无自动重试;SDK 内部是否重试未确认 | 未确认 | 未确认 | 登录失败、token 不可用、授权不足 | 公司 Logto 账号和 Chrome identity 回调权限 | 未确认 | 登录失败或 token 不可用时不进入业务流程,或后端调用失败 |
|
|
||||||
| COS 更新清单 | 检查插件新版本 | 插件弹窗 | `latest.json` URL | 最新版本、ZIP URL、说明 PDF URL、发布时间、更新说明 | 否 | 未确认 | 弹窗打开后单次检查 | 未确认 | 无自动重试 | 0 | 无 | 请求失败、清单格式错误、URL 非 HTTPS | 清单和安装包需公开可读 | COS 存储和流量成本未确认 | 检查失败时弹窗显示无法检查更新,不影响已安装插件主功能 |
|
|
||||||
| Chrome downloads | 下载 CSV、更新包和说明 PDF | CSV 导出、插件更新 | 文件名、下载 URL 或 data URL | 浏览器下载任务 | 否 | 浏览器自身规则,未确认 | 由浏览器管理 | 未确认 | 无自动重试 | 0 | 无 | 下载权限不可用、浏览器拦截、URL 无效 | Chrome `downloads` 权限 | 未确认 | CSV 下载失败时回退页面链接下载;更新包下载失败时显示错误 |
|
|
||||||
|
|
||||||
凭证和权限说明:
|
|
||||||
|
|
||||||
- 星图接口依赖当前浏览器中的星图网页登录态和 cookie;
|
|
||||||
- 公司后端指标查询和批次提交依赖 Logto access token;
|
|
||||||
- 插件需要 Chrome `downloads`、`identity`、`storage` 权限;
|
|
||||||
- COS 更新包需要公开可读;
|
|
||||||
- 具体接口额度、调用成本、账号级限制均未确认。
|
|
||||||
|
|
||||||
## 5. 数据处理规则
|
|
||||||
|
|
||||||
### 5.1 数据来源
|
|
||||||
|
|
||||||
数据主要来自四类来源:
|
|
||||||
|
|
||||||
- 星图市场页面和列表接口:达人 ID、名称、地区、报价、粉丝、内容主题、预期播放、互动率、完播率等基础字段;
|
|
||||||
- 星图详情类接口:看后搜率、画像、商业能力、传播指标;
|
|
||||||
- 公司 talent-search 后端:秒思 api 指标;
|
|
||||||
- 使用者输入:勾选状态、星图 ID、批次名称、字段选择和阈值筛选条件。
|
|
||||||
|
|
||||||
### 5.2 保留规则
|
|
||||||
|
|
||||||
- 有有效达人 ID 和达人名称的记录会进入页面记录集合;
|
|
||||||
- 按 ID 导出时,有效 ID 即使部分接口失败也会生成 CSV 行;
|
|
||||||
- CSV 基础字段、导出状态、失败原因等固定字段会保留;
|
|
||||||
- 字段选择只影响可选数据字段,不删除固定标识字段。
|
|
||||||
|
|
||||||
### 5.3 过滤规则
|
|
||||||
|
|
||||||
- 星图页面中缺少达人 ID 或达人名称的行会跳过;
|
|
||||||
- 导出选中达人数据必须有已勾选达人;
|
|
||||||
- 画像导出只保留当前导出范围内的已勾选达人;
|
|
||||||
- 普通导出或提交批次如果存在已选达人,会优先保留当前范围内的已选达人;
|
|
||||||
- 如果当前范围内没有任何已选达人,普通导出或提交批次会回退为当前范围全部达人;
|
|
||||||
- 传播指标阈值筛选启用后,不满足全部阈值的达人会被过滤;
|
|
||||||
- 按 ID 导出时,非 16 到 20 位纯数字 token 会过滤。
|
|
||||||
|
|
||||||
### 5.4 去重规则
|
|
||||||
|
|
||||||
- 多页导出和后台分页导出按达人 ID 合并去重;
|
|
||||||
- 按 ID 导出对输入 ID 去重;
|
|
||||||
- 合并时优先保留已有的非空字段;
|
|
||||||
- 指标字段会在有新非空值时补充。
|
|
||||||
|
|
||||||
### 5.5 字段补充和合并规则
|
|
||||||
|
|
||||||
- 星图列表数据作为基础;
|
|
||||||
- 看后搜率优先使用列表中已有值,不完整时再请求星图指标接口;
|
|
||||||
- 秒思指标按 star_id 从后端补充;
|
|
||||||
- 传播指标按多组参数生成不同列,不合并同名业务指标;
|
|
||||||
- `代表视频`可能被读取,但不会进入最终普通市场 CSV;
|
|
||||||
- 画像和商业能力字段追加在基础字段之后;
|
|
||||||
- 传播指标字段追加在基础字段、看后搜率和秒思 api 字段之后。
|
|
||||||
|
|
||||||
### 5.6 覆盖规则
|
|
||||||
|
|
||||||
- 当前页面会话内的内存记录会被补充和合并;
|
|
||||||
- 项目不会把 CSV 导出结果写回星图;
|
|
||||||
- 批次提交会向后端创建或提交数据,是否覆盖已有批次未确认;
|
|
||||||
- 字段选择配置会保存到浏览器 localStorage,下次导出沿用。
|
|
||||||
|
|
||||||
### 5.7 写入位置
|
|
||||||
|
|
||||||
- CSV 写入浏览器下载目录;
|
|
||||||
- 批次数据写入批次提交后端;
|
|
||||||
- 字段选择写入浏览器 localStorage;
|
|
||||||
- 登录状态由 Logto Chrome 扩展 SDK 管理;
|
|
||||||
- 项目本身不维护持久任务数据库。
|
|
||||||
|
|
||||||
### 5.8 写入失败处理
|
|
||||||
|
|
||||||
- CSV 下载失败会提示或使用备用下载方式;
|
|
||||||
- 批次提交失败会提示失败原因,本次提交不视为成功;
|
|
||||||
- 字段选择保存失败会被忽略,后续可能恢复默认全选字段;
|
|
||||||
- 后端指标补充失败不会阻止 CSV 生成,只会导致字段为空。
|
|
||||||
|
|
||||||
### 5.9 重复运行时数据变化
|
|
||||||
|
|
||||||
- 重复导出会重新生成新的 CSV 文件;
|
|
||||||
- 重复按 ID 导出不会写入外部业务系统;
|
|
||||||
- 重复提交批次可能在后端产生重复批次,是否由后端去重未确认;
|
|
||||||
- 页面内已缓存的成功指标可能在当前会话中复用,刷新页面后会重新读取。
|
|
||||||
|
|
||||||
## 6. 重复执行、中断恢复和幂等性
|
|
||||||
|
|
||||||
- 任务可以重复执行。
|
|
||||||
- CSV 导出重复执行会产生新的下载文件,不会覆盖星图或后端数据。
|
|
||||||
- 按 ID 导出重复执行会重新请求接口并生成新 CSV,不会写入后端批次。
|
|
||||||
- 字段选择重复保存会覆盖浏览器本地保存的字段选择。
|
|
||||||
- 批次提交重复执行是否会重复创建批次:未确认。当前前端没有批次幂等键,也不生成 batchId。
|
|
||||||
- 批次提交是否覆盖已有结果:未确认,取决于后端。
|
|
||||||
- 任务跑到一半失败后,当前项目没有持久任务状态记录。
|
|
||||||
- 导出中断后再次执行会从本次流程开头重新收集和请求,不会从上次中断点恢复。
|
|
||||||
- 页面会话中的指标缓存可以减少同一页面内重复请求,但不等同于断点续跑。
|
|
||||||
- 浏览器刷新、插件重载或页面关闭会丢失内存中的中间状态。
|
|
||||||
- 多页导出按达人 ID 去重,重复分页读取同一达人不会在 CSV 中重复出现。
|
|
||||||
- 按 ID 导出对输入 ID 去重,重复输入同一 ID 不会产生重复行。
|
|
||||||
- 阈值筛选没有持久状态,重新执行时按当前页面输入框值重新判断。
|
|
||||||
|
|
||||||
重复执行相对安全的操作:
|
|
||||||
|
|
||||||
- 重新打开页面;
|
|
||||||
- 重新导出 CSV;
|
|
||||||
- 重新按 ID 导出;
|
|
||||||
- 重新检查更新;
|
|
||||||
- 重新保存字段选择。
|
|
||||||
|
|
||||||
重复执行可能有风险的操作:
|
|
||||||
|
|
||||||
- 重复点击提交批次;
|
|
||||||
- 修改后端地址后提交批次;
|
|
||||||
- 使用不同星图筛选条件或不同字段选择重复导出后,拿多个 CSV 混用;
|
|
||||||
- 阈值输入为空或变化后重复提交,可能导致提交达人集合变化。
|
|
||||||
|
|
||||||
未确认项:
|
|
||||||
|
|
||||||
- 后端批次接口是否按批次名称、用户或达人集合去重;
|
|
||||||
- 后端批次接口是否允许空达人列表;
|
|
||||||
- 后端是否有任务状态、失败重试或重复提交保护;
|
|
||||||
- Logto token 刷新失败后是否有 SDK 内部重试。
|
|
||||||
|
|
||||||
## 7. 项目使用方式
|
|
||||||
|
|
||||||
### 7.1 使用前准备
|
|
||||||
|
|
||||||
使用前需要准备:
|
|
||||||
|
|
||||||
- Google Chrome 浏览器;
|
|
||||||
- 内部发布的 `star-chart-search-enhancer-internal.zip`;
|
|
||||||
- 可访问巨量星图的账号;
|
|
||||||
- 可访问公司 Logto 登录系统的账号;
|
|
||||||
- 如果要提交批次,需要批次提交后端可访问;
|
|
||||||
- 如果要查询秒思 api 指标,需要 talent-search 后端授权可用。
|
|
||||||
|
|
||||||
需要的权限和凭证:
|
|
||||||
|
|
||||||
- Chrome 扩展加载权限;
|
|
||||||
- 星图网页登录态;
|
|
||||||
- Logto 登录态;
|
|
||||||
- talent-search 后端读取权限;
|
|
||||||
- 批次提交后端所需权限,具体 scope 是否只需当前配置未确认。
|
|
||||||
|
|
||||||
### 7.2 本地安装使用
|
|
||||||
|
|
||||||
1. 解压内部 ZIP;
|
|
||||||
2. 打开 `chrome://extensions`;
|
|
||||||
3. 开启开发者模式;
|
|
||||||
4. 点击加载已解压的扩展程序;
|
|
||||||
5. 选择解压后的 `dist` 文件夹;
|
|
||||||
6. 确认扩展 ID 为 `pkjopdibdnomhogjheclhnknmejccffg`;
|
|
||||||
7. 固定插件图标;
|
|
||||||
8. 点击插件图标并登录;
|
|
||||||
9. 打开 `https://xingtu.cn/ad/creator/market`;
|
|
||||||
10. 等待插件工具栏出现。
|
|
||||||
|
|
||||||
### 7.3 执行一次完整导出任务
|
|
||||||
|
|
||||||
1. 在星图市场中完成筛选;
|
|
||||||
2. 等待页面列表加载完成;
|
|
||||||
3. 勾选需要导出的达人;
|
|
||||||
4. 可选:点击 `选择字段` 调整 CSV 字段;
|
|
||||||
5. 可选:填写传播指标阈值;
|
|
||||||
6. 点击 `导出选中达人数据`;
|
|
||||||
7. 等待状态提示从导出中消失或浏览器下载完成;
|
|
||||||
8. 在下载目录或 Chrome 下载列表中查看 CSV;
|
|
||||||
9. 检查 `导出状态` 和 `失败原因` 字段。
|
|
||||||
|
|
||||||
### 7.4 按 ID 执行导出任务
|
|
||||||
|
|
||||||
1. 点击 `按星图ID导出`;
|
|
||||||
2. 粘贴达人星图 ID,每行一个或用分隔符隔开;
|
|
||||||
3. 点击确认;
|
|
||||||
4. 查看识别数量、去重后数量和非法数量提示;
|
|
||||||
5. 等待 CSV 下载;
|
|
||||||
6. 检查每行导出状态和失败原因。
|
|
||||||
|
|
||||||
### 7.5 执行一次批次提交
|
|
||||||
|
|
||||||
1. 在星图市场中完成筛选;
|
|
||||||
2. 可选:勾选需要提交的达人;
|
|
||||||
3. 可选:填写传播指标阈值;
|
|
||||||
4. 点击 `提交批次`;
|
|
||||||
5. 输入批次名称;
|
|
||||||
6. 等待页面提示 `批次提交成功`;
|
|
||||||
7. 到后端系统确认批次是否生成。
|
|
||||||
|
|
||||||
### 7.6 只执行某个子流程
|
|
||||||
|
|
||||||
- 只登录:打开插件弹窗并登录;
|
|
||||||
- 只检查更新:登录后打开插件弹窗查看版本更新区域;
|
|
||||||
- 只选择字段:在星图市场页点击 `选择字段` 并保存;
|
|
||||||
- 只按 ID 导出:不需要勾选页面达人,直接点击 `按星图ID导出`;
|
|
||||||
- 只提交批次:不需要先导出 CSV,但需要登录并输入批次名称。
|
|
||||||
|
|
||||||
### 7.7 重新执行任务
|
|
||||||
|
|
||||||
- 重新导出:直接再次点击导出按钮;
|
|
||||||
- 重新按 ID 导出:再次打开 ID 输入弹窗并确认;
|
|
||||||
- 重新提交批次:再次点击提交批次并输入批次名称。注意可能创建重复批次,后端幂等未确认;
|
|
||||||
- 页面异常后重试:刷新星图页面,等待工具栏重新出现后再执行。
|
|
||||||
|
|
||||||
### 7.8 确认任务完成
|
|
||||||
|
|
||||||
- 导出任务:浏览器下载列表出现 CSV 文件;
|
|
||||||
- 按 ID 导出:CSV 文件名包含按 ID 导出标识,且文件中有导出状态列;
|
|
||||||
- 批次提交:页面显示 `批次提交成功`,并在后端系统中能看到对应批次;
|
|
||||||
- 插件更新:重新加载后插件版本显示为新版本。
|
|
||||||
|
|
||||||
### 7.9 危险操作
|
|
||||||
|
|
||||||
- 重复点击 `提交批次`;
|
|
||||||
- 修改后端地址后未验证就发给同事使用;
|
|
||||||
- 删除正在被 Chrome 加载的 `dist` 文件夹;
|
|
||||||
- 随意修改 Logto 配置、后端地址、scope 或 manifest key;
|
|
||||||
- 在未确认星图筛选条件的情况下提交全部范围达人;
|
|
||||||
- 阈值筛选填错导致提交集合被大幅改变。
|
|
||||||
|
|
||||||
### 7.10 不能随便改的参数
|
|
||||||
|
|
||||||
- 固定扩展 ID 相关配置;
|
|
||||||
- Logto appId、endpoint、resource、scope;
|
|
||||||
- 批次提交后端地址;
|
|
||||||
- talent-search 后端地址;
|
|
||||||
- COS 更新清单 URL;
|
|
||||||
- 星图接口参数含义;
|
|
||||||
- 传播指标列名规则;
|
|
||||||
- 批次 payload 字段。
|
|
||||||
|
|
||||||
### 7.11 运行和发布方式
|
|
||||||
|
|
||||||
本地开发运行:
|
|
||||||
|
|
||||||
- 安装依赖:`npm install`;
|
|
||||||
- 运行测试:`npm test`;
|
|
||||||
- 开发构建:`npm run build`;
|
|
||||||
- 然后在 Chrome 中加载 `dist`。
|
|
||||||
|
|
||||||
内部发布构建:
|
|
||||||
|
|
||||||
- 运行测试;
|
|
||||||
- 执行内部打包;
|
|
||||||
- 生成 ZIP 和更新清单;
|
|
||||||
- 上传或分发给同事;
|
|
||||||
- 同事仍需人工解压和加载。
|
|
||||||
|
|
||||||
定时任务运行:
|
|
||||||
|
|
||||||
- 当前项目未确认存在服务端定时任务。
|
|
||||||
- 插件更新发布可通过 tag 触发 Drone 发布流程。
|
|
||||||
|
|
||||||
## 8. 运行参数和配置说明
|
|
||||||
|
|
||||||
| 配置名称 | 作用 | 默认值 | 可选值 | 修改影响 | 是否需要重启/重载 | 风险 |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| 扩展 ID / manifest key | 固定 Chrome 扩展身份 | `pkjopdibdnomhogjheclhnknmejccffg` | 未确认 | 影响 Logto 回调、用户安装识别、更新连续性 | 需要重新构建并重新加载插件 | 改错会导致登录失败或同事装到不同插件 |
|
|
||||||
| Logto endpoint | 登录服务地址 | `https://login-api.intelligrow.cn` | 未确认 | 影响登录和 token 获取 | 需要重新构建并重新加载插件 | 登录不可用 |
|
|
||||||
| Logto appId | 插件登录应用 ID | `i4jkllbvih0554r4n0fd3` | 未确认 | 影响登录应用和回调校验 | 需要重新构建并重新加载插件 | 登录不可用 |
|
|
||||||
| apiResource | token 资源地址 | `https://talent-search.intelligrow.cn` | 未确认 | 影响后端 token audience/resource | 需要重新构建并重新加载插件 | 后端接口 401/403 |
|
|
||||||
| scopes | 登录申请权限 | `openid`、`profile`、`offline_access`、`talent-search:read` | 未确认 | 影响 token 权限 | 需要重新登录;通常也需重新构建 | 后端读写权限不足 |
|
|
||||||
| enableDevAuthPanel | 是否显示开发调试面板 | `false` | `true` / `false` | 影响插件弹窗是否显示调试入口 | 需要重新构建并重新加载插件 | 暴露调试入口 |
|
|
||||||
| 批次提交后端地址 | 提交达人批次的目标服务 | 当前工作区为 `http://localhost:8083` | 其他后端地址未确认 | 影响批次提交去向 | 需要重新构建并重新加载插件 | 提交到错误环境、重复或丢失业务数据 |
|
|
||||||
| 后端指标服务地址 | 查询秒思 api 指标 | `https://talent-search.intelligrow.cn` | 未确认 | 影响页面增强列和 CSV 秒思字段 | 需要重新构建并重新加载插件 | 指标为空、权限错误或消耗错误环境额度 |
|
|
||||||
| COS 更新清单 URL | 插件弹窗检查新版本 | `https://wksgx-1343191620.cos.ap-nanjing.myqcloud.com/star-chart-search-enhancer/latest.json` | 其他 HTTPS URL | 影响更新提示和安装包下载 | 需要重新构建并重新加载插件 | 用户无法更新或下载错误包 |
|
|
||||||
| 导出范围 | 决定收集哪些页面达人 | 当前工具栏默认隐藏,默认值为前 5 页;当前用户主入口通常要求勾选达人 | 当前页、前 5 页、前 10 页、全部、自定义 | 影响导出或提交的达人集合 | 不需要重启 | 范围过大增加接口调用量 |
|
|
||||||
| 传播指标阈值 | 导出或提交前二次过滤达人 | 空 | 非负数字 | 影响最终保留达人集合 | 不需要重启 | 填错会过滤掉目标达人 |
|
|
||||||
| 字段选择 | 控制 CSV 可选字段 | 默认全选 | 可选字段集合 | 影响 CSV 列 | 不需要重启,会本地保存 | 漏导业务字段 |
|
|
||||||
|
|
||||||
## 9. 任务执行和结果确认
|
|
||||||
|
|
||||||
### 9.1 任务开始标志
|
|
||||||
|
|
||||||
- 插件登录任务:点击插件弹窗登录按钮后跳转登录;
|
|
||||||
- 页面增强任务:星图市场页面出现插件工具栏和新增列;
|
|
||||||
- 导出任务:状态区出现 `画像导出中`、`按ID画像导出中` 或类似导出中提示;
|
|
||||||
- 批次提交任务:点击提交并输入批次名称后,状态区出现提交中提示;
|
|
||||||
- 更新检查任务:插件弹窗显示正在检查更新。
|
|
||||||
|
|
||||||
### 9.2 执行中状态
|
|
||||||
|
|
||||||
- 工具栏按钮会被禁用,防止同一流程中重复点击;
|
|
||||||
- 多页收集时会显示页码进度;
|
|
||||||
- 画像和按 ID 导出会显示当前处理序号;
|
|
||||||
- 页面指标列可能显示 `加载中...`;
|
|
||||||
- 后端指标可能显示暂无数据或加载失败。
|
|
||||||
|
|
||||||
### 9.3 成功完成标志
|
|
||||||
|
|
||||||
- CSV 导出:浏览器下载列表出现 CSV 文件;
|
|
||||||
- 按 ID 导出:下载完成,CSV 中每行有导出状态;
|
|
||||||
- 批次提交:页面提示 `批次提交成功`;
|
|
||||||
- 更新下载:弹窗提示已触发下载;
|
|
||||||
- 页面增强:指标列显示具体数值或明确的暂无数据状态。
|
|
||||||
|
|
||||||
### 9.4 部分成功表现
|
|
||||||
|
|
||||||
- 单个达人部分接口失败时,CSV 中该行 `导出状态` 为部分成功或失败,并在 `失败原因` 中列明失败项;
|
|
||||||
- 秒思 api 指标失败时,对应字段为空或页面显示失败,不一定影响 CSV 下载;
|
|
||||||
- 传播指标某组参数失败时,对应字段为空;
|
|
||||||
- 画像部分失败时,其他画像或商业能力字段仍可保留;
|
|
||||||
- 后端指标查询不到某个达人时显示暂无数据。
|
|
||||||
|
|
||||||
### 9.5 失败表现
|
|
||||||
|
|
||||||
- 未登录:星图页面显示登录提示,不出现业务工具栏;
|
|
||||||
- 没勾选就导出选中达人数据:提示请先勾选;
|
|
||||||
- 当前范围无选中达人:提示当前导出范围内没有选中的达人;
|
|
||||||
- 全部画像失败:提示画像导出失败,不下载 CSV;
|
|
||||||
- 按 ID 没有有效 ID:提示请输入有效的达人星图 ID;
|
|
||||||
- 批次提交失败:状态区显示接口错误或通用失败提示;
|
|
||||||
- 更新清单失败:弹窗显示暂时无法检查更新或错误信息。
|
|
||||||
|
|
||||||
### 9.6 最终结果查看位置
|
|
||||||
|
|
||||||
- CSV:浏览器默认下载目录或 Chrome 下载列表;
|
|
||||||
- 批次:批次提交后端系统,具体查看入口未确认;
|
|
||||||
- 插件版本:插件弹窗或 Chrome 扩展详情页;
|
|
||||||
- 登录状态:插件弹窗;
|
|
||||||
- 页面增强结果:星图达人市场页面新增列。
|
|
||||||
|
|
||||||
### 9.7 管理者确认标准
|
|
||||||
|
|
||||||
管理者确认一次任务是否达到预期时,应关注:
|
|
||||||
|
|
||||||
- 使用者是否登录了正确插件;
|
|
||||||
- 星图筛选条件是否符合业务目标;
|
|
||||||
- 导出的 CSV 行数是否符合已选达人或输入 ID 数量;
|
|
||||||
- CSV 中 `导出状态` 是否大部分为成功;
|
|
||||||
- 关键业务字段是否有值,例如内容数据、效果预估、画像、秒思 api 数据;
|
|
||||||
- 批次提交是否在后端生成对应批次;
|
|
||||||
- 批次名称、创建人和达人数量是否符合预期;
|
|
||||||
- 是否存在重复提交批次。
|
|
||||||
|
|
||||||
## 10. 重要限制和风险
|
|
||||||
|
|
||||||
- 星图接口调用额度限制:未确认。
|
|
||||||
- 星图接口限流规则:未确认。
|
|
||||||
- 公司后端接口额度限制:未确认。
|
|
||||||
- 批次提交接口幂等规则:未确认。
|
|
||||||
- 项目没有持久任务状态记录,不支持真正断点续跑。
|
|
||||||
- 导出范围过大时,会产生大量星图接口请求,运行时间会变长。
|
|
||||||
- 当前传播指标补充和筛选存在并发请求;是否有显式并发上限未确认,当前未看到稳定的业务级并发限制配置。
|
|
||||||
- 星图页面结构变化可能导致工具栏挂载、列表读取或翻页失效。
|
|
||||||
- 星图网页登录态过期会导致接口失败。
|
|
||||||
- Logto token 不可用会导致后端指标和批次提交失败。
|
|
||||||
- 批次提交重复执行可能产生重复批次。
|
|
||||||
- 修改后端地址可能把数据提交到错误环境。
|
|
||||||
- 字段选择保存到本地浏览器,换浏览器或清理数据后会恢复默认。
|
|
||||||
- 更新包仍需人工解压和重载,下载新版本不等于插件已更新。
|
|
||||||
- 删除或移动本地 `dist` 文件夹会导致已加载插件失效。
|
|
||||||
- 扩展 ID、Logto 回调和 manifest key 强相关,改错会导致登录失败。
|
|
||||||
- `http://localhost:8083` 作为批次提交默认地址时,只适合本机后端可用的场景;生产或同事环境是否适用未确认。
|
|
||||||
- 下载 CSV 不会自动校验业务完整性,需要使用者或管理者检查导出状态和关键字段。
|
|
||||||
- 传播指标阈值填错会改变导出或提交达人集合。
|
|
||||||
|
|
||||||
## 11. 未确认项清单
|
|
||||||
|
|
||||||
- 星图各接口是否存在明确限流:未确认。
|
|
||||||
- 星图各接口账号级、IP 级或 cookie 级调用额度:未确认。
|
|
||||||
- 星图各接口失败后是否由浏览器或服务端内部重试:未确认。
|
|
||||||
- 公司后端 `history/talents/search` 是否有分页上限、查询数量上限或限流:未确认。
|
|
||||||
- 公司后端 `history/talents/search` 的接口超时时间:未确认。
|
|
||||||
- 批次提交后端的生产地址:未确认;当前工作区配置为 `http://localhost:8083`。
|
|
||||||
- 批次提交后端是否支持幂等:未确认。
|
|
||||||
- 批次提交后端是否允许空达人列表:未确认。
|
|
||||||
- 批次提交后端是否会覆盖同名批次:未确认。
|
|
||||||
- 批次提交后端生成的 7 位数字批次 ID 的具体规则:未确认。
|
|
||||||
- 批次提交后端的查看入口和管理流程:未确认。
|
|
||||||
- 当前 Logto scope 是否足够覆盖批次写操作:未确认。
|
|
||||||
- COS 更新清单的权限、缓存和发布审核规则:未确认。
|
|
||||||
- Drone 发布是否为唯一正式发布方式:未确认。
|
|
||||||
- 插件是否有统一日志、错误上报或审计记录:未确认。
|
|
||||||
- 页面增强指标是否有跨页面或跨浏览器持久缓存:未确认;当前仅确认有页面会话内记录。
|
|
||||||
- 导出全部页面时最多导出多少页:后台静默导出当前最多尝试 200 页;真实星图侧上限未确认。
|
|
||||||
- 传播指标请求是否应该限制并发:需求文档曾提出需要限制,但当前真实业务级并发控制未确认。
|
|
||||||
- 后续业务系统如何消费批次:未确认。
|
|
||||||
|
|
||||||
## 12. 文档维护规则
|
|
||||||
|
|
||||||
从现在开始,任何模型或工程师在修改本项目代码时,都必须遵守以下规则:
|
|
||||||
|
|
||||||
1. 修改代码前,先检查本流程文档是否描述了相关流程;
|
|
||||||
2. 如果代码改动影响流程、接口、配置、数据处理规则、使用方式、任务执行方式、结果确认方式、限流、重试、超时、并发或幂等性,必须同步更新本文档;
|
|
||||||
3. 如果代码行为和文档描述发生冲突,必须以当前真实行为为准更新文档;
|
|
||||||
4. 如果改动较大,但判断不需要更新文档,必须说明原因;
|
|
||||||
5. 每次完成较大代码改动后,最终回复必须明确说明:
|
|
||||||
- 是否检查了流程文档;
|
|
||||||
- 是否更新了流程文档;
|
|
||||||
- 更新了哪些部分;
|
|
||||||
- 如果没有更新,为什么不需要更新。
|
|
||||||
|
|
||||||
以下情况都视为必须检查文档的较大改动:
|
|
||||||
|
|
||||||
- 改变整体流程顺序;
|
|
||||||
- 新增、删除或调整流程步骤;
|
|
||||||
- 新增、删除或替换外部接口;
|
|
||||||
- 修改接口参数、鉴权方式、分页方式、限流方式、重试方式、超时规则或并发规则;
|
|
||||||
- 修改数据过滤、去重、合并、覆盖、写入规则;
|
|
||||||
- 修改任务启动方式、运行参数或配置项;
|
|
||||||
- 修改任务成功、失败、部分成功的判断方式;
|
|
||||||
- 修改重复执行、中断恢复或幂等性行为;
|
|
||||||
- 修改最终结果的产出位置或格式;
|
|
||||||
- 修改会影响项目使用者操作方式的任何内容。
|
|
||||||
@ -59,7 +59,7 @@ const hostPermissionsByTarget = {
|
|||||||
"https://*.xingtu.cn/ad/creator/market*",
|
"https://*.xingtu.cn/ad/creator/market*",
|
||||||
"https://login-api.intelligrow.cn/*",
|
"https://login-api.intelligrow.cn/*",
|
||||||
"https://talent-search.intelligrow.cn/*",
|
"https://talent-search.intelligrow.cn/*",
|
||||||
"http://localhost:8083/*",
|
"http://192.168.31.21:8083/*",
|
||||||
"https://*/*"
|
"https://*/*"
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@ -11,9 +11,10 @@ import type {
|
|||||||
AudienceProfileKind,
|
AudienceProfileKind,
|
||||||
AudienceProfileResult,
|
AudienceProfileResult,
|
||||||
BusinessAbilityDurationKind,
|
BusinessAbilityDurationKind,
|
||||||
BusinessAbilityEstimateMetrics
|
BusinessAbilityEstimateMetrics,
|
||||||
|
BusinessAbilityVideoKind,
|
||||||
|
BusinessAbilityVideoMetrics
|
||||||
} from "./audience-profile-types";
|
} from "./audience-profile-types";
|
||||||
import { buildSpreadInfoColumns } from "./spread-info";
|
|
||||||
|
|
||||||
type AudienceProfileCsvColumn = {
|
type AudienceProfileCsvColumn = {
|
||||||
header: string;
|
header: string;
|
||||||
@ -59,6 +60,29 @@ const CROWD_LABELS = [
|
|||||||
"小镇青年"
|
"小镇青年"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const BUSINESS_VIDEO_LAYOUTS: Array<{
|
||||||
|
key: BusinessAbilityVideoKind;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ key: "personalVideo", label: "个人视频" },
|
||||||
|
{ key: "xingtuVideo", label: "星图视频" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const BUSINESS_VIDEO_METRIC_LAYOUTS: Array<{
|
||||||
|
key: keyof BusinessAbilityVideoMetrics;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ key: "medianPlay", label: "播放量中位数" },
|
||||||
|
{ key: "finishRate", label: "完播率" },
|
||||||
|
{ key: "interactionRate", label: "互动率" },
|
||||||
|
{ key: "publishedItems", label: "发布作品" },
|
||||||
|
{ key: "averageDuration", label: "平均时长" },
|
||||||
|
{ key: "averageLike", label: "平均点赞" },
|
||||||
|
{ key: "averageComment", label: "平均评论" },
|
||||||
|
{ key: "averageShare", label: "平均转发" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const BUSINESS_VIDEO_SECTION_LABEL = "内容数据";
|
||||||
const BUSINESS_ESTIMATE_SECTION_LABEL = "效果预估";
|
const BUSINESS_ESTIMATE_SECTION_LABEL = "效果预估";
|
||||||
|
|
||||||
const BUSINESS_ESTIMATE_LAYOUTS: Array<{
|
const BUSINESS_ESTIMATE_LAYOUTS: Array<{
|
||||||
@ -122,7 +146,7 @@ export function listAudienceProfileSelectableFieldGroups(): AudienceProfileCsvFi
|
|||||||
label: "秒思api数据"
|
label: "秒思api数据"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: buildSpreadInfoColumns(),
|
headers: buildBusinessVideoColumns().map((column) => column.header),
|
||||||
label: "内容数据"
|
label: "内容数据"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -160,7 +184,19 @@ function listAudienceProfileSelectableHeaders(): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildBusinessAbilityColumns(): AudienceProfileCsvColumn[] {
|
function buildBusinessAbilityColumns(): AudienceProfileCsvColumn[] {
|
||||||
return buildBusinessEstimateColumns();
|
return [...buildBusinessVideoColumns(), ...buildBusinessEstimateColumns()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBusinessVideoColumns(): AudienceProfileCsvColumn[] {
|
||||||
|
return [
|
||||||
|
...BUSINESS_VIDEO_LAYOUTS.flatMap((videoLayout) =>
|
||||||
|
BUSINESS_VIDEO_METRIC_LAYOUTS.map((metricLayout) => ({
|
||||||
|
header: `${BUSINESS_VIDEO_SECTION_LABEL}-${videoLayout.label}-${metricLayout.label}`,
|
||||||
|
readValue: (row: AudienceProfileExportRow) =>
|
||||||
|
readBusinessVideoValue(row, videoLayout.key, metricLayout.key)
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBusinessEstimateColumns(): AudienceProfileCsvColumn[] {
|
function buildBusinessEstimateColumns(): AudienceProfileCsvColumn[] {
|
||||||
@ -175,6 +211,19 @@ function buildBusinessEstimateColumns(): AudienceProfileCsvColumn[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readBusinessVideoValue(
|
||||||
|
row: AudienceProfileExportRow,
|
||||||
|
videoKey: BusinessAbilityVideoKind,
|
||||||
|
metricKey: keyof BusinessAbilityVideoMetrics
|
||||||
|
): string {
|
||||||
|
const businessAbility = row.businessAbility;
|
||||||
|
if (!businessAbility || businessAbility.status !== "success") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return businessAbility.videos[videoKey]?.[metricKey] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
function readBusinessEstimateValue(
|
function readBusinessEstimateValue(
|
||||||
row: AudienceProfileExportRow,
|
row: AudienceProfileExportRow,
|
||||||
durationKey: BusinessAbilityDurationKind,
|
durationKey: BusinessAbilityDurationKind,
|
||||||
|
|||||||
@ -42,6 +42,21 @@ export interface AudienceProfileExportRow {
|
|||||||
record: MarketRecord;
|
record: MarketRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BusinessAbilityVideoKind =
|
||||||
|
| "personalVideo"
|
||||||
|
| "xingtuVideo";
|
||||||
|
|
||||||
|
export interface BusinessAbilityVideoMetrics {
|
||||||
|
averageComment: string;
|
||||||
|
averageDuration: string;
|
||||||
|
averageLike: string;
|
||||||
|
averageShare: string;
|
||||||
|
finishRate: string;
|
||||||
|
interactionRate: string;
|
||||||
|
medianPlay: string;
|
||||||
|
publishedItems: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type BusinessAbilityDurationKind =
|
export type BusinessAbilityDurationKind =
|
||||||
| "oneToTwenty"
|
| "oneToTwenty"
|
||||||
| "twentyToSixty"
|
| "twentyToSixty"
|
||||||
@ -59,6 +74,7 @@ export interface BusinessAbilitySuccess {
|
|||||||
Record<BusinessAbilityDurationKind, BusinessAbilityEstimateMetrics>
|
Record<BusinessAbilityDurationKind, BusinessAbilityEstimateMetrics>
|
||||||
>;
|
>;
|
||||||
status: "success";
|
status: "success";
|
||||||
|
videos: Partial<Record<BusinessAbilityVideoKind, BusinessAbilityVideoMetrics>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BusinessAbilityFailure {
|
export interface BusinessAbilityFailure {
|
||||||
|
|||||||
@ -3,7 +3,8 @@ import type {
|
|||||||
BusinessAbilityDurationKind,
|
BusinessAbilityDurationKind,
|
||||||
BusinessAbilityEstimateMetrics,
|
BusinessAbilityEstimateMetrics,
|
||||||
BusinessAbilityResult,
|
BusinessAbilityResult,
|
||||||
BusinessAbilitySuccess
|
BusinessAbilitySuccess,
|
||||||
|
BusinessAbilityVideoMetrics
|
||||||
} from "./audience-profile-types";
|
} from "./audience-profile-types";
|
||||||
|
|
||||||
interface FetchResponseLike {
|
interface FetchResponseLike {
|
||||||
@ -22,6 +23,11 @@ interface BusinessAbilityClientOptions {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VIDEO_TYPES = {
|
||||||
|
personalVideo: 1,
|
||||||
|
xingtuVideo: 2
|
||||||
|
} as const;
|
||||||
|
|
||||||
export function createBusinessAbilityClient(
|
export function createBusinessAbilityClient(
|
||||||
options: BusinessAbilityClientOptions = {}
|
options: BusinessAbilityClientOptions = {}
|
||||||
) {
|
) {
|
||||||
@ -31,20 +37,33 @@ export function createBusinessAbilityClient(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
async loadBusinessAbility(record: MarketRecord): Promise<BusinessAbilityResult> {
|
async loadBusinessAbility(record: MarketRecord): Promise<BusinessAbilityResult> {
|
||||||
|
const personalVideo = await loadJson(
|
||||||
|
buildBusinessAbilityVideoUrl(record.authorId, baseUrl, VIDEO_TYPES.personalVideo)
|
||||||
|
);
|
||||||
|
const xingtuVideo = await loadJson(
|
||||||
|
buildBusinessAbilityVideoUrl(record.authorId, baseUrl, VIDEO_TYPES.xingtuVideo)
|
||||||
|
);
|
||||||
const estimates = await loadJson(
|
const estimates = await loadJson(
|
||||||
buildBusinessAbilityEstimateUrl(record.authorId, baseUrl)
|
buildBusinessAbilityEstimateUrl(record.authorId, baseUrl)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!estimates.ok) {
|
if (!personalVideo.ok || !xingtuVideo.ok || !estimates.ok) {
|
||||||
return {
|
return {
|
||||||
failureReason: estimates.failureReason,
|
failureReason:
|
||||||
|
personalVideo.failureReason ??
|
||||||
|
xingtuVideo.failureReason ??
|
||||||
|
estimates.failureReason,
|
||||||
status: "failed"
|
status: "failed"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
estimates: mapBusinessAbilityEstimateResponse(estimates.payload),
|
estimates: mapBusinessAbilityEstimateResponse(estimates.payload),
|
||||||
status: "success"
|
status: "success",
|
||||||
|
videos: {
|
||||||
|
personalVideo: mapBusinessAbilityVideoResponse(personalVideo.payload),
|
||||||
|
xingtuVideo: mapBusinessAbilityVideoResponse(xingtuVideo.payload)
|
||||||
|
}
|
||||||
} satisfies BusinessAbilitySuccess;
|
} satisfies BusinessAbilitySuccess;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -82,6 +101,22 @@ export function createBusinessAbilityClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildBusinessAbilityVideoUrl(
|
||||||
|
authorId: string,
|
||||||
|
baseUrl: string,
|
||||||
|
videoType: number
|
||||||
|
): string {
|
||||||
|
const url = new URL("/gw/api/data_sp/get_author_spread_info", baseUrl);
|
||||||
|
url.searchParams.set("o_author_id", authorId);
|
||||||
|
url.searchParams.set("platform_source", "1");
|
||||||
|
url.searchParams.set("platform_channel", "1");
|
||||||
|
url.searchParams.set("type", String(videoType));
|
||||||
|
url.searchParams.set("flow_type", "0");
|
||||||
|
url.searchParams.set("only_assign", "true");
|
||||||
|
url.searchParams.set("range", "2");
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
export function buildBusinessAbilityEstimateUrl(
|
export function buildBusinessAbilityEstimateUrl(
|
||||||
authorId: string,
|
authorId: string,
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
@ -94,6 +129,25 @@ export function buildBusinessAbilityEstimateUrl(
|
|||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mapBusinessAbilityVideoResponse(
|
||||||
|
payload: unknown
|
||||||
|
): BusinessAbilityVideoMetrics {
|
||||||
|
const data = getPayloadData(payload);
|
||||||
|
|
||||||
|
return {
|
||||||
|
averageComment: formatWan(readNumber(data?.comment_avg)),
|
||||||
|
averageDuration: formatDuration(readNumber(data?.avg_duration)),
|
||||||
|
averageLike: formatWan(readNumber(data?.like_avg)),
|
||||||
|
averageShare: formatWan(readNumber(data?.share_avg)),
|
||||||
|
finishRate: formatBasisPointRate(readNestedNumber(data, "play_over_rate", "value")),
|
||||||
|
interactionRate: formatBasisPointRate(
|
||||||
|
readNestedNumber(data, "interact_rate", "value")
|
||||||
|
),
|
||||||
|
medianPlay: formatWan(readNumber(data?.play_mid)),
|
||||||
|
publishedItems: formatPublishedItems(readNumber(data?.item_num))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function mapBusinessAbilityEstimateResponse(
|
export function mapBusinessAbilityEstimateResponse(
|
||||||
payload: unknown
|
payload: unknown
|
||||||
): Partial<Record<BusinessAbilityDurationKind, BusinessAbilityEstimateMetrics>> {
|
): Partial<Record<BusinessAbilityDurationKind, BusinessAbilityEstimateMetrics>> {
|
||||||
@ -123,6 +177,30 @@ export function mapBusinessAbilityEstimateResponse(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPublishedItems(value: number | null): string {
|
||||||
|
if (value === null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return value > 0 && value < 5 ? "<5" : formatDecimal(value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(value: number | null): string {
|
||||||
|
if (value === null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatDecimal(value / 100, 0)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBasisPointRate(value: number | null): string {
|
||||||
|
if (value === null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${formatDecimal(value / 100, 1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatDecimalRate(value: number | null): string {
|
function formatDecimalRate(value: number | null): string {
|
||||||
if (value === null) {
|
if (value === null) {
|
||||||
return "缺失";
|
return "缺失";
|
||||||
@ -160,6 +238,19 @@ function formatFixedDecimal(value: number | null, digits: number): string {
|
|||||||
return value.toFixed(digits);
|
return value.toFixed(digits);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readNestedNumber(
|
||||||
|
data: Record<string, unknown> | null,
|
||||||
|
objectKey: string,
|
||||||
|
valueKey: string
|
||||||
|
): number | null {
|
||||||
|
const objectValue = data?.[objectKey];
|
||||||
|
if (!isRecord(objectValue)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return readNumber(objectValue[valueKey]);
|
||||||
|
}
|
||||||
|
|
||||||
function readNumber(value: unknown): number | null {
|
function readNumber(value: unknown): number | null {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { normalizeRateDisplay } from "../../shared/rate-normalizer";
|
import { normalizeRateDisplay } from "../../shared/rate-normalizer";
|
||||||
import { escapeCsvCell } from "../../shared/csv";
|
import { escapeCsvCell } from "../../shared/csv";
|
||||||
import { buildSpreadInfoColumns } from "./spread-info";
|
|
||||||
import type { MarketRecord } from "./types";
|
import type { MarketRecord } from "./types";
|
||||||
|
|
||||||
export type CsvColumn = {
|
export type CsvColumn = {
|
||||||
@ -75,11 +74,6 @@ const BACKEND_METRIC_COLUMNS: CsvColumn[] = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const SPREAD_INFO_COLUMNS: CsvColumn[] = buildSpreadInfoColumns().map((header) => ({
|
|
||||||
header,
|
|
||||||
readValue: (record: MarketRecord) => record.spreadMetrics?.[header] ?? ""
|
|
||||||
}));
|
|
||||||
|
|
||||||
export function listRateCsvHeaders(): string[] {
|
export function listRateCsvHeaders(): string[] {
|
||||||
return RATE_COLUMNS.map((column) => column.header);
|
return RATE_COLUMNS.map((column) => column.header);
|
||||||
}
|
}
|
||||||
@ -100,12 +94,7 @@ export function buildMarketCsv(records: MarketRecord[]): string {
|
|||||||
|
|
||||||
export function buildMarketCsvColumns(records: MarketRecord[]): CsvColumn[] {
|
export function buildMarketCsvColumns(records: MarketRecord[]): CsvColumn[] {
|
||||||
const baseColumns = buildBaseColumns(records);
|
const baseColumns = buildBaseColumns(records);
|
||||||
return [
|
return [...baseColumns, ...RATE_COLUMNS, ...BACKEND_METRIC_COLUMNS];
|
||||||
...baseColumns,
|
|
||||||
...RATE_COLUMNS,
|
|
||||||
...BACKEND_METRIC_COLUMNS,
|
|
||||||
...SPREAD_INFO_COLUMNS
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
|
export function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
|
||||||
|
|||||||
@ -76,7 +76,6 @@ type MarketDataRow = {
|
|||||||
location?: string;
|
location?: string;
|
||||||
price21To60s?: string;
|
price21To60s?: string;
|
||||||
rates?: AfterSearchRates;
|
rates?: AfterSearchRates;
|
||||||
spreadAuthorId?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface MarketRowDom {
|
export interface MarketRowDom {
|
||||||
@ -89,7 +88,6 @@ export interface MarketRowDom {
|
|||||||
personalCell: HTMLElement;
|
personalCell: HTMLElement;
|
||||||
price21To60s?: string;
|
price21To60s?: string;
|
||||||
rates?: AfterSearchRates;
|
rates?: AfterSearchRates;
|
||||||
spreadAuthorId?: string;
|
|
||||||
row: HTMLElement;
|
row: HTMLElement;
|
||||||
selectionCheckbox: HTMLInputElement;
|
selectionCheckbox: HTMLInputElement;
|
||||||
singleCell: HTMLElement;
|
singleCell: HTMLElement;
|
||||||
@ -681,7 +679,6 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
|
|||||||
row: authorCell,
|
row: authorCell,
|
||||||
selectionCheckbox,
|
selectionCheckbox,
|
||||||
singleCell,
|
singleCell,
|
||||||
spreadAuthorId: fallbackMarketRow?.spreadAuthorId,
|
|
||||||
visibilityTargets: rowCells
|
visibilityTargets: rowCells
|
||||||
} satisfies MarketRowDom
|
} satisfies MarketRowDom
|
||||||
];
|
];
|
||||||
@ -1354,8 +1351,7 @@ function readSerializedMarketRows(
|
|||||||
? {
|
? {
|
||||||
singleVideoAfterSearchRate
|
singleVideoAfterSearchRate
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined
|
||||||
spreadAuthorId: readString(record.spreadAuthorId) ?? undefined
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((row) => Boolean(row.authorId || row.authorName));
|
.filter((row) => Boolean(row.authorId || row.authorName));
|
||||||
@ -1677,11 +1673,7 @@ function mergeMarketDataRows(
|
|||||||
baseRow.price21To60s,
|
baseRow.price21To60s,
|
||||||
preferredRow.price21To60s
|
preferredRow.price21To60s
|
||||||
),
|
),
|
||||||
rates: mergeRates(baseRow.rates, preferredRow.rates),
|
rates: mergeRates(baseRow.rates, preferredRow.rates)
|
||||||
spreadAuthorId: mergeNonEmptyString(
|
|
||||||
baseRow.spreadAuthorId,
|
|
||||||
preferredRow.spreadAuthorId
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -31,10 +31,8 @@ import { createMarketApiClient } from "./api-client";
|
|||||||
import { createExportRangeController } from "./export-range-controller";
|
import { createExportRangeController } from "./export-range-controller";
|
||||||
import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar";
|
import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar";
|
||||||
import { createSilentExportController } from "./silent-export-controller";
|
import { createSilentExportController } from "./silent-export-controller";
|
||||||
import { createSpreadInfoClient, matchesSpreadThresholds } from "./spread-info";
|
|
||||||
import {
|
import {
|
||||||
readToolbarExportTarget,
|
readToolbarExportTarget,
|
||||||
readToolbarSpreadFilter,
|
|
||||||
setToolbarBusyState,
|
setToolbarBusyState,
|
||||||
setToolbarExportStatus
|
setToolbarExportStatus
|
||||||
} from "./plugin-toolbar";
|
} from "./plugin-toolbar";
|
||||||
@ -56,8 +54,7 @@ import type {
|
|||||||
MarketExportTarget,
|
MarketExportTarget,
|
||||||
MarketRecord,
|
MarketRecord,
|
||||||
MarketRowSnapshot,
|
MarketRowSnapshot,
|
||||||
MarketSortState,
|
MarketSortState
|
||||||
SpreadThresholdFilter
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
interface MutationObserverLike {
|
interface MutationObserverLike {
|
||||||
@ -82,11 +79,6 @@ export interface CreateMarketControllerOptions {
|
|||||||
target: AudienceProfileRequestTarget
|
target: AudienceProfileRequestTarget
|
||||||
) => Promise<AudienceProfileResult>;
|
) => Promise<AudienceProfileResult>;
|
||||||
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
||||||
loadSpreadFilterMetrics?: (
|
|
||||||
spreadAuthorId: string,
|
|
||||||
config: SpreadThresholdFilter["config"]
|
|
||||||
) => Promise<Record<string, string | undefined>>;
|
|
||||||
loadSpreadMetrics?: (spreadAuthorId: string) => Promise<Record<string, string>>;
|
|
||||||
searchBackendMetrics?: (starIds: string[]) => Promise<
|
searchBackendMetrics?: (starIds: string[]) => Promise<
|
||||||
Array<BackendMetrics & { starId: string }>
|
Array<BackendMetrics & { starId: string }>
|
||||||
>;
|
>;
|
||||||
@ -109,16 +101,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
const audienceProfileClient = createAudienceProfileClient();
|
const audienceProfileClient = createAudienceProfileClient();
|
||||||
const authorBaseClient = createAuthorBaseClient();
|
const authorBaseClient = createAuthorBaseClient();
|
||||||
const businessAbilityClient = createBusinessAbilityClient();
|
const businessAbilityClient = createBusinessAbilityClient();
|
||||||
const spreadInfoClient = createSpreadInfoClient();
|
|
||||||
const sendRuntimeMessage = createRuntimeMessageSender();
|
const sendRuntimeMessage = createRuntimeMessageSender();
|
||||||
const resultStore = options.resultStore ?? createMarketResultStore();
|
const resultStore = options.resultStore ?? createMarketResultStore();
|
||||||
const loadAuthorMetrics =
|
const loadAuthorMetrics =
|
||||||
options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
|
options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
|
||||||
const loadSpreadFilterMetrics =
|
|
||||||
options.loadSpreadFilterMetrics ??
|
|
||||||
spreadInfoClient.loadAuthorSpreadMetricSnapshot;
|
|
||||||
const loadSpreadMetrics =
|
|
||||||
options.loadSpreadMetrics ?? spreadInfoClient.loadAuthorSpreadMetrics;
|
|
||||||
const searchBackendMetrics =
|
const searchBackendMetrics =
|
||||||
options.searchBackendMetrics ??
|
options.searchBackendMetrics ??
|
||||||
(hasRuntimeMessageSender() ? (starIds: string[]) => readBackendMetrics(sendRuntimeMessage, starIds) : null);
|
(hasRuntimeMessageSender() ? (starIds: string[]) => readBackendMetrics(sendRuntimeMessage, starIds) : null);
|
||||||
@ -220,22 +206,13 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
|
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const spreadFilter = readToolbarSpreadFilter(toolbar);
|
|
||||||
if (spreadFilter.error) {
|
|
||||||
setToolbarExportStatus(toolbar, spreadFilter.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setToolbarBusyState(toolbar, true);
|
setToolbarBusyState(toolbar, true);
|
||||||
try {
|
try {
|
||||||
const records = filterRecordsBySelection(
|
const records = filterRecordsBySelection(
|
||||||
await applySpreadThresholdFilter(
|
|
||||||
await exportRecords(exportTarget.target, "导出中", {
|
await exportRecords(exportTarget.target, "导出中", {
|
||||||
includeSpreadMetrics: true,
|
|
||||||
showDetailedProgress: selectedAuthorIds.size === 0
|
showDetailedProgress: selectedAuthorIds.size === 0
|
||||||
}),
|
})
|
||||||
spreadFilter.filter
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
options.onCsvReady?.(buildCsv(records));
|
options.onCsvReady?.(buildCsv(records));
|
||||||
setToolbarExportStatus(toolbar, "");
|
setToolbarExportStatus(toolbar, "");
|
||||||
@ -265,7 +242,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
try {
|
try {
|
||||||
const selectedRecords = filterRecordsBySelectionStrict(
|
const selectedRecords = filterRecordsBySelectionStrict(
|
||||||
await exportRecords(exportTarget.target, "画像导出中", {
|
await exportRecords(exportTarget.target, "画像导出中", {
|
||||||
includeSpreadMetrics: true,
|
|
||||||
showDetailedProgress: false
|
showDetailedProgress: false
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -393,11 +369,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
|
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const spreadFilter = readToolbarSpreadFilter(toolbar);
|
|
||||||
if (spreadFilter.error) {
|
|
||||||
setToolbarExportStatus(toolbar, spreadFilter.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const batchName = await promptBatchName();
|
const batchName = await promptBatchName();
|
||||||
if (batchName === null) {
|
if (batchName === null) {
|
||||||
@ -413,15 +384,12 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
try {
|
try {
|
||||||
const hasSelectedAuthors = selectedAuthorIds.size > 0;
|
const hasSelectedAuthors = selectedAuthorIds.size > 0;
|
||||||
const records = filterRecordsBySelection(
|
const records = filterRecordsBySelection(
|
||||||
await applySpreadThresholdFilter(
|
|
||||||
await exportRecords(
|
await exportRecords(
|
||||||
exportTarget.target,
|
exportTarget.target,
|
||||||
hasSelectedAuthors ? "提交已选达人中" : "提交中",
|
hasSelectedAuthors ? "提交已选达人中" : "提交中",
|
||||||
{
|
{
|
||||||
showDetailedProgress: !hasSelectedAuthors
|
showDetailedProgress: !hasSelectedAuthors
|
||||||
}
|
}
|
||||||
),
|
|
||||||
spreadFilter.filter
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const authState = await getAuthState();
|
const authState = await getAuthState();
|
||||||
@ -739,7 +707,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
target: MarketExportTarget,
|
target: MarketExportTarget,
|
||||||
inProgressLabel = "导出中",
|
inProgressLabel = "导出中",
|
||||||
progressOptions: {
|
progressOptions: {
|
||||||
includeSpreadMetrics?: boolean;
|
|
||||||
showDetailedProgress?: boolean;
|
showDetailedProgress?: boolean;
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<MarketRecord[]> {
|
): Promise<MarketRecord[]> {
|
||||||
@ -749,9 +716,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
|
|
||||||
if (target.mode === "count" && target.pageCount <= 1) {
|
if (target.mode === "count" && target.pageCount <= 1) {
|
||||||
await prepareCurrentPageForExport();
|
await prepareCurrentPageForExport();
|
||||||
return hydrateExportRecords(getVisibleOrderedRecords(), {
|
return getVisibleOrderedRecords();
|
||||||
includeSpreadMetrics: progressOptions.includeSpreadMetrics ?? false
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const silentExportRecords = await silentExportController.exportRecords(target);
|
const silentExportRecords = await silentExportController.exportRecords(target);
|
||||||
@ -760,10 +725,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
silentExportRecords.map((record) => ({
|
silentExportRecords.map((record) => ({
|
||||||
...record,
|
...record,
|
||||||
status: record.status ?? "idle"
|
status: record.status ?? "idle"
|
||||||
})),
|
}))
|
||||||
{
|
|
||||||
includeSpreadMetrics: progressOptions.includeSpreadMetrics ?? false
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -798,37 +760,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
return selectedRecords.length > 0 ? selectedRecords : records;
|
return selectedRecords.length > 0 ? selectedRecords : records;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applySpreadThresholdFilter(
|
|
||||||
records: MarketRecord[],
|
|
||||||
filter: SpreadThresholdFilter | undefined
|
|
||||||
): Promise<MarketRecord[]> {
|
|
||||||
if (!filter) {
|
|
||||||
return records;
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchedAuthorIds = new Set<string>();
|
|
||||||
await Promise.all(
|
|
||||||
records.map(async (record) => {
|
|
||||||
const spreadAuthorId = record.spreadAuthorId;
|
|
||||||
if (!spreadAuthorId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const metrics = await loadSpreadFilterMetrics(
|
|
||||||
spreadAuthorId,
|
|
||||||
filter.config
|
|
||||||
);
|
|
||||||
if (matchesSpreadThresholds(metrics, filter.thresholds)) {
|
|
||||||
matchedAuthorIds.add(record.authorId);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return records.filter((record) => matchedAuthorIds.has(record.authorId));
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] {
|
function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] {
|
||||||
if (selectedAuthorIds.size === 0) {
|
if (selectedAuthorIds.size === 0) {
|
||||||
return [];
|
return [];
|
||||||
@ -879,17 +810,13 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
loadAuthorBaseInfoSafe(authorId),
|
loadAuthorBaseInfoSafe(authorId),
|
||||||
loadAuthorMetricsSafe(authorId)
|
loadAuthorMetricsSafe(authorId)
|
||||||
]);
|
]);
|
||||||
const spreadMetrics = baseRecord.spreadAuthorId
|
|
||||||
? await loadSpreadMetrics(baseRecord.spreadAuthorId)
|
|
||||||
: {};
|
|
||||||
const recordForRequests = {
|
const recordForRequests = {
|
||||||
...baseRecord,
|
...baseRecord,
|
||||||
authorName: baseRecord.authorName || authorId,
|
authorName: baseRecord.authorName || authorId,
|
||||||
...(metricsResult.success ? { rates: metricsResult.rates } : {}),
|
...(metricsResult.success ? { rates: metricsResult.rates } : {}),
|
||||||
...(backendMetrics
|
...(backendMetrics
|
||||||
? { backendMetrics, backendMetricsStatus: "success" as const }
|
? { backendMetrics, backendMetricsStatus: "success" as const }
|
||||||
: {}),
|
: {})
|
||||||
...(Object.keys(spreadMetrics).length > 0 ? { spreadMetrics } : {})
|
|
||||||
};
|
};
|
||||||
const [profiles, businessAbility] = await Promise.all([
|
const [profiles, businessAbility] = await Promise.all([
|
||||||
loadAudienceProfileSet(recordForRequests),
|
loadAudienceProfileSet(recordForRequests),
|
||||||
@ -1073,12 +1000,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
await runSyncCycle();
|
await runSyncCycle();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateExportRecords(
|
async function hydrateExportRecords(records: MarketRecord[]): Promise<MarketRecord[]> {
|
||||||
records: MarketRecord[],
|
|
||||||
options: {
|
|
||||||
includeSpreadMetrics?: boolean;
|
|
||||||
} = {}
|
|
||||||
): Promise<MarketRecord[]> {
|
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
resultStore.upsertMarketRow(record);
|
resultStore.upsertMarketRow(record);
|
||||||
const existingRecord = resultStore.getRecord(record.authorId);
|
const existingRecord = resultStore.getRecord(record.authorId);
|
||||||
@ -1144,32 +1066,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.includeSpreadMetrics) {
|
|
||||||
await hydrateSpreadMetricsForRecords(records);
|
|
||||||
}
|
|
||||||
|
|
||||||
return records.map((record) => toMarketRecord(record));
|
return records.map((record) => toMarketRecord(record));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateSpreadMetricsForRecords(
|
|
||||||
records: MarketRecord[]
|
|
||||||
): Promise<void> {
|
|
||||||
await Promise.all(
|
|
||||||
records.map(async (record) => {
|
|
||||||
const storedRecord = resultStore.getRecord(record.authorId) ?? record;
|
|
||||||
const spreadAuthorId = storedRecord.spreadAuthorId ?? record.spreadAuthorId;
|
|
||||||
if (!spreadAuthorId || storedRecord.spreadMetrics) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const spreadMetrics = await loadSpreadMetrics(spreadAuthorId);
|
|
||||||
if (Object.keys(spreadMetrics).length > 0) {
|
|
||||||
resultStore.setSpreadMetricsSuccess(record.authorId, spreadMetrics);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function harvestCurrentPageForExport(): Promise<void> {
|
async function harvestCurrentPageForExport(): Promise<void> {
|
||||||
let hydrationSnapshot = await collectCurrentPageSnapshotsUntilSettled();
|
let hydrationSnapshot = await collectCurrentPageSnapshotsUntilSettled();
|
||||||
if (
|
if (
|
||||||
@ -1249,10 +1148,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
existingRecord?.price21To60s,
|
existingRecord?.price21To60s,
|
||||||
rowSnapshot.price21To60s
|
rowSnapshot.price21To60s
|
||||||
);
|
);
|
||||||
const spreadAuthorId = mergeStringValue(
|
|
||||||
existingRecord?.spreadAuthorId,
|
|
||||||
rowSnapshot.spreadAuthorId
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
...existingRecord,
|
...existingRecord,
|
||||||
...rowSnapshot,
|
...rowSnapshot,
|
||||||
@ -1274,11 +1169,6 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
location,
|
location,
|
||||||
price21To60s,
|
price21To60s,
|
||||||
rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates),
|
rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates),
|
||||||
spreadAuthorId,
|
|
||||||
spreadMetrics: mergeFieldMap(
|
|
||||||
existingRecord?.spreadMetrics,
|
|
||||||
rowSnapshot.spreadMetrics
|
|
||||||
),
|
|
||||||
status: existingRecord?.status ?? "idle"
|
status: existingRecord?.status ?? "idle"
|
||||||
} satisfies MarketRecord;
|
} satisfies MarketRecord;
|
||||||
}
|
}
|
||||||
@ -1587,8 +1477,7 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
|
|||||||
hasDirectRatesSource: rowDom.hasDirectRatesSource,
|
hasDirectRatesSource: rowDom.hasDirectRatesSource,
|
||||||
location: rowDom.location,
|
location: rowDom.location,
|
||||||
price21To60s: rowDom.price21To60s,
|
price21To60s: rowDom.price21To60s,
|
||||||
rates: rowDom.rates,
|
rates: rowDom.rates
|
||||||
spreadAuthorId: rowDom.spreadAuthorId
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -68,9 +68,7 @@ export function mapMarketListRow(
|
|||||||
? {
|
? {
|
||||||
singleVideoAfterSearchRate
|
singleVideoAfterSearchRate
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined
|
||||||
spreadAuthorId:
|
|
||||||
readString(readMarketFieldValue(row, attributeDatas, "id")) ?? undefined
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -206,8 +206,7 @@ function readSerializedMarketRows() {
|
|||||||
authorName:
|
authorName:
|
||||||
readString(attributeDatas.nickname) ?? readString(row.nick_name) ?? "",
|
readString(attributeDatas.nickname) ?? readString(row.nick_name) ?? "",
|
||||||
coreUserId: readString(attributeDatas.core_user_id) ?? undefined,
|
coreUserId: readString(attributeDatas.core_user_id) ?? undefined,
|
||||||
singleVideoAfterSearchRate,
|
singleVideoAfterSearchRate
|
||||||
spreadAuthorId: readString(attributeDatas.id) ?? undefined
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((row) => Boolean(row.authorId || row.authorName));
|
.filter((row) => Boolean(row.authorId || row.authorName));
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
MarketExportScope,
|
MarketExportScope,
|
||||||
MarketExportTarget,
|
MarketExportTarget
|
||||||
SpreadThresholdFilter
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export interface PluginToolbarHandlers {
|
export interface PluginToolbarHandlers {
|
||||||
@ -21,11 +20,6 @@ export interface PluginToolbarDom {
|
|||||||
exportCustomPagesInput: HTMLInputElement;
|
exportCustomPagesInput: HTMLInputElement;
|
||||||
exportRangeSelect: HTMLSelectElement;
|
exportRangeSelect: HTMLSelectElement;
|
||||||
exportStatusText: HTMLElement;
|
exportStatusText: HTMLElement;
|
||||||
spreadFilterFlowTypeSelect: HTMLSelectElement;
|
|
||||||
spreadFilterOnlyAssignSelect: HTMLSelectElement;
|
|
||||||
spreadFilterRangeSelect: HTMLSelectElement;
|
|
||||||
spreadFilterTypeSelect: HTMLSelectElement;
|
|
||||||
spreadThresholdInputs: Record<keyof SpreadThresholdFilter["thresholds"], HTMLInputElement>;
|
|
||||||
root: HTMLElement;
|
root: HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,82 +114,15 @@ export function ensurePluginToolbar(
|
|||||||
exportStatusText.dataset.pluginExportStatus = "text";
|
exportStatusText.dataset.pluginExportStatus = "text";
|
||||||
applyStatusStyles(exportStatusText);
|
applyStatusStyles(exportStatusText);
|
||||||
|
|
||||||
const spreadFilterTypeSelect = document.createElement("select");
|
|
||||||
spreadFilterTypeSelect.dataset.pluginSpreadFilter = "type";
|
|
||||||
appendOption(spreadFilterTypeSelect, "1", "个人视频");
|
|
||||||
appendOption(spreadFilterTypeSelect, "2", "星图视频");
|
|
||||||
spreadFilterTypeSelect.value = "1";
|
|
||||||
|
|
||||||
const spreadFilterOnlyAssignSelect = document.createElement("select");
|
|
||||||
spreadFilterOnlyAssignSelect.dataset.pluginSpreadFilter = "onlyAssign";
|
|
||||||
appendOption(spreadFilterOnlyAssignSelect, "false", "不限指派");
|
|
||||||
appendOption(spreadFilterOnlyAssignSelect, "true", "只看指派");
|
|
||||||
spreadFilterOnlyAssignSelect.value = "false";
|
|
||||||
|
|
||||||
const spreadFilterFlowTypeSelect = document.createElement("select");
|
|
||||||
spreadFilterFlowTypeSelect.dataset.pluginSpreadFilter = "flowType";
|
|
||||||
appendOption(spreadFilterFlowTypeSelect, "0", "不排除营销");
|
|
||||||
appendOption(spreadFilterFlowTypeSelect, "1", "排除营销");
|
|
||||||
spreadFilterFlowTypeSelect.value = "0";
|
|
||||||
|
|
||||||
const spreadFilterRangeSelect = document.createElement("select");
|
|
||||||
spreadFilterRangeSelect.dataset.pluginSpreadFilter = "range";
|
|
||||||
appendOption(spreadFilterRangeSelect, "2", "近30天");
|
|
||||||
appendOption(spreadFilterRangeSelect, "3", "近90天");
|
|
||||||
spreadFilterRangeSelect.value = "2";
|
|
||||||
|
|
||||||
const spreadThresholdInputs = createSpreadThresholdInputs(document);
|
|
||||||
|
|
||||||
const panel = document.createElement("div");
|
|
||||||
panel.dataset.pluginToolbarPanel = "root";
|
|
||||||
applyToolbarPanelStyles(panel);
|
|
||||||
|
|
||||||
const firstRow = document.createElement("div");
|
|
||||||
firstRow.dataset.pluginToolbarRow = "primary";
|
|
||||||
applyToolbarRowStyles(firstRow);
|
|
||||||
|
|
||||||
const secondRow = document.createElement("div");
|
|
||||||
secondRow.dataset.pluginToolbarRow = "thresholds";
|
|
||||||
applyToolbarRowStyles(secondRow);
|
|
||||||
|
|
||||||
const dataGroup = document.createElement("div");
|
|
||||||
dataGroup.dataset.pluginToolbarGroup = "data";
|
|
||||||
applyToolbarGroupStyles(dataGroup);
|
|
||||||
dataGroup.append(
|
|
||||||
audienceProfileExportButton,
|
|
||||||
audienceProfileByIdExportButton,
|
|
||||||
audienceProfileFieldButton,
|
|
||||||
batchSubmitButton
|
|
||||||
);
|
|
||||||
|
|
||||||
const videoGroup = document.createElement("div");
|
|
||||||
videoGroup.dataset.pluginToolbarGroup = "video";
|
|
||||||
applyToolbarGroupStyles(videoGroup);
|
|
||||||
videoGroup.append(
|
|
||||||
createToolbarGroupTitle(document, "视频口径"),
|
|
||||||
spreadFilterTypeSelect,
|
|
||||||
spreadFilterOnlyAssignSelect,
|
|
||||||
spreadFilterFlowTypeSelect,
|
|
||||||
spreadFilterRangeSelect
|
|
||||||
);
|
|
||||||
|
|
||||||
const thresholdGroup = document.createElement("div");
|
|
||||||
thresholdGroup.dataset.pluginToolbarGroup = "thresholds";
|
|
||||||
applyThresholdGroupStyles(thresholdGroup);
|
|
||||||
thresholdGroup.append(
|
|
||||||
...createSpreadThresholdControls(document, spreadThresholdInputs)
|
|
||||||
);
|
|
||||||
|
|
||||||
const thresholdTitle = createToolbarGroupTitle(document, "传播指标筛选");
|
|
||||||
firstRow.append(dataGroup, videoGroup, exportStatusText);
|
|
||||||
secondRow.append(thresholdTitle, thresholdGroup);
|
|
||||||
panel.append(firstRow, secondRow);
|
|
||||||
|
|
||||||
root.append(
|
root.append(
|
||||||
exportRangeSelect,
|
exportRangeSelect,
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
exportButton,
|
exportButton,
|
||||||
panel
|
audienceProfileExportButton,
|
||||||
|
audienceProfileByIdExportButton,
|
||||||
|
audienceProfileFieldButton,
|
||||||
|
batchSubmitButton,
|
||||||
|
exportStatusText
|
||||||
);
|
);
|
||||||
|
|
||||||
document.body.appendChild(root);
|
document.body.appendChild(root);
|
||||||
@ -206,12 +133,7 @@ export function ensurePluginToolbar(
|
|||||||
batchSubmitButton,
|
batchSubmitButton,
|
||||||
exportButton,
|
exportButton,
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
exportRangeSelect,
|
exportRangeSelect
|
||||||
spreadFilterFlowTypeSelect,
|
|
||||||
spreadFilterOnlyAssignSelect,
|
|
||||||
spreadFilterRangeSelect,
|
|
||||||
spreadFilterTypeSelect,
|
|
||||||
...spreadThresholdInputs
|
|
||||||
});
|
});
|
||||||
ensureToolbarMounted(root, document);
|
ensureToolbarMounted(root, document);
|
||||||
|
|
||||||
@ -240,30 +162,7 @@ export function ensurePluginToolbar(
|
|||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
exportRangeSelect,
|
exportRangeSelect,
|
||||||
exportStatusText,
|
exportStatusText,
|
||||||
root,
|
root
|
||||||
spreadFilterFlowTypeSelect,
|
|
||||||
spreadFilterOnlyAssignSelect,
|
|
||||||
spreadFilterRangeSelect,
|
|
||||||
spreadFilterTypeSelect,
|
|
||||||
spreadThresholdInputs
|
|
||||||
});
|
|
||||||
});
|
|
||||||
spreadFilterTypeSelect.addEventListener("change", () => {
|
|
||||||
syncSpreadFilterControlState({
|
|
||||||
audienceProfileByIdExportButton,
|
|
||||||
audienceProfileExportButton,
|
|
||||||
audienceProfileFieldButton,
|
|
||||||
batchSubmitButton,
|
|
||||||
exportButton,
|
|
||||||
exportCustomPagesInput,
|
|
||||||
exportRangeSelect,
|
|
||||||
exportStatusText,
|
|
||||||
root,
|
|
||||||
spreadFilterFlowTypeSelect,
|
|
||||||
spreadFilterOnlyAssignSelect,
|
|
||||||
spreadFilterRangeSelect,
|
|
||||||
spreadFilterTypeSelect,
|
|
||||||
spreadThresholdInputs
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -276,15 +175,9 @@ export function ensurePluginToolbar(
|
|||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
exportRangeSelect,
|
exportRangeSelect,
|
||||||
exportStatusText,
|
exportStatusText,
|
||||||
spreadFilterFlowTypeSelect,
|
|
||||||
spreadFilterOnlyAssignSelect,
|
|
||||||
spreadFilterRangeSelect,
|
|
||||||
spreadFilterTypeSelect,
|
|
||||||
spreadThresholdInputs,
|
|
||||||
root
|
root
|
||||||
} satisfies PluginToolbarDom;
|
} satisfies PluginToolbarDom;
|
||||||
syncCustomPagesInputVisibility(toolbarDom);
|
syncCustomPagesInputVisibility(toolbarDom);
|
||||||
syncSpreadFilterControlState(toolbarDom);
|
|
||||||
|
|
||||||
return toolbarDom;
|
return toolbarDom;
|
||||||
}
|
}
|
||||||
@ -300,123 +193,6 @@ function appendOption(
|
|||||||
select.appendChild(option);
|
select.appendChild(option);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSpreadThresholdInputs(
|
|
||||||
document: Document
|
|
||||||
): Record<keyof SpreadThresholdFilter["thresholds"], HTMLInputElement> {
|
|
||||||
return {
|
|
||||||
averageCommentCount: createSpreadThresholdInput(
|
|
||||||
document,
|
|
||||||
"averageCommentCount"
|
|
||||||
),
|
|
||||||
averageDuration: createSpreadThresholdInput(
|
|
||||||
document,
|
|
||||||
"averageDuration"
|
|
||||||
),
|
|
||||||
averageLikeCount: createSpreadThresholdInput(
|
|
||||||
document,
|
|
||||||
"averageLikeCount"
|
|
||||||
),
|
|
||||||
averageShareCount: createSpreadThresholdInput(
|
|
||||||
document,
|
|
||||||
"averageShareCount"
|
|
||||||
),
|
|
||||||
finishRate: createSpreadThresholdInput(document, "finishRate"),
|
|
||||||
interactionRate: createSpreadThresholdInput(document, "interactionRate"),
|
|
||||||
playMedian: createSpreadThresholdInput(document, "playMedian")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSpreadThresholdInput(
|
|
||||||
document: Document,
|
|
||||||
key: keyof SpreadThresholdFilter["thresholds"]
|
|
||||||
): HTMLInputElement {
|
|
||||||
const input = document.createElement("input");
|
|
||||||
input.type = "number";
|
|
||||||
input.min = "0";
|
|
||||||
input.step = getSpreadThresholdStep(key);
|
|
||||||
input.dataset.pluginSpreadThreshold = key;
|
|
||||||
return input;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSpreadThresholdStep(
|
|
||||||
key: keyof SpreadThresholdFilter["thresholds"]
|
|
||||||
): string {
|
|
||||||
return key === "finishRate" || key === "interactionRate" ? "0.1" : "1";
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSpreadThresholdControls(
|
|
||||||
document: Document,
|
|
||||||
inputs: Record<keyof SpreadThresholdFilter["thresholds"], HTMLInputElement>
|
|
||||||
): HTMLElement[] {
|
|
||||||
const controls: HTMLElement[] = [];
|
|
||||||
const entries: Array<[string, string, HTMLInputElement]> = [
|
|
||||||
["评论", "条", inputs.averageCommentCount],
|
|
||||||
["时长", "秒", inputs.averageDuration],
|
|
||||||
["点赞", "次", inputs.averageLikeCount],
|
|
||||||
["转发", "次", inputs.averageShareCount],
|
|
||||||
["完播率", "%", inputs.finishRate],
|
|
||||||
["互动率", "%", inputs.interactionRate],
|
|
||||||
["播放中位数", "次", inputs.playMedian]
|
|
||||||
];
|
|
||||||
|
|
||||||
entries.forEach(([label, unit, input], index) => {
|
|
||||||
if (index > 0) {
|
|
||||||
controls.push(createSpreadThresholdConjunction(document));
|
|
||||||
}
|
|
||||||
|
|
||||||
const wrapper = document.createElement("label");
|
|
||||||
wrapper.dataset.pluginSpreadThresholdControl = input.dataset.pluginSpreadThreshold;
|
|
||||||
applySpreadThresholdControlStyles(wrapper);
|
|
||||||
|
|
||||||
const labelText = document.createElement("span");
|
|
||||||
labelText.textContent = label;
|
|
||||||
|
|
||||||
const operator = document.createElement("b");
|
|
||||||
operator.dataset.pluginSpreadThresholdOperator = "gte";
|
|
||||||
operator.textContent = "≥";
|
|
||||||
|
|
||||||
const unitText = document.createElement("span");
|
|
||||||
unitText.dataset.pluginSpreadThresholdUnit = input.dataset.pluginSpreadThreshold;
|
|
||||||
unitText.textContent = unit;
|
|
||||||
|
|
||||||
wrapper.append(labelText, operator, input, unitText);
|
|
||||||
controls.push(wrapper);
|
|
||||||
});
|
|
||||||
|
|
||||||
return controls;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSpreadThresholdConjunction(document: Document): HTMLElement {
|
|
||||||
const conjunction = document.createElement("span");
|
|
||||||
conjunction.dataset.pluginSpreadThresholdConjunction = "and";
|
|
||||||
conjunction.textContent = "且";
|
|
||||||
applySpreadThresholdConjunctionStyles(conjunction);
|
|
||||||
return conjunction;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSpreadThresholdInputs(
|
|
||||||
root: HTMLElement
|
|
||||||
): Record<keyof SpreadThresholdFilter["thresholds"], HTMLInputElement> {
|
|
||||||
return {
|
|
||||||
averageCommentCount: readSpreadThresholdInput(root, "averageCommentCount"),
|
|
||||||
averageDuration: readSpreadThresholdInput(root, "averageDuration"),
|
|
||||||
averageLikeCount: readSpreadThresholdInput(root, "averageLikeCount"),
|
|
||||||
averageShareCount: readSpreadThresholdInput(root, "averageShareCount"),
|
|
||||||
finishRate: readSpreadThresholdInput(root, "finishRate"),
|
|
||||||
interactionRate: readSpreadThresholdInput(root, "interactionRate"),
|
|
||||||
playMedian: readSpreadThresholdInput(root, "playMedian")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSpreadThresholdInput(
|
|
||||||
root: HTMLElement,
|
|
||||||
key: keyof SpreadThresholdFilter["thresholds"]
|
|
||||||
): HTMLInputElement {
|
|
||||||
return root.querySelector(
|
|
||||||
`[data-plugin-spread-threshold="${key}"]`
|
|
||||||
) as HTMLInputElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||||
const toolbarDom = {
|
const toolbarDom = {
|
||||||
audienceProfileByIdExportButton: root.querySelector(
|
audienceProfileByIdExportButton: root.querySelector(
|
||||||
@ -443,23 +219,9 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
|||||||
exportStatusText: root.querySelector(
|
exportStatusText: root.querySelector(
|
||||||
'[data-plugin-export-status="text"]'
|
'[data-plugin-export-status="text"]'
|
||||||
) as HTMLElement,
|
) as HTMLElement,
|
||||||
spreadFilterFlowTypeSelect: root.querySelector(
|
|
||||||
'[data-plugin-spread-filter="flowType"]'
|
|
||||||
) as HTMLSelectElement,
|
|
||||||
spreadFilterOnlyAssignSelect: root.querySelector(
|
|
||||||
'[data-plugin-spread-filter="onlyAssign"]'
|
|
||||||
) as HTMLSelectElement,
|
|
||||||
spreadFilterRangeSelect: root.querySelector(
|
|
||||||
'[data-plugin-spread-filter="range"]'
|
|
||||||
) as HTMLSelectElement,
|
|
||||||
spreadFilterTypeSelect: root.querySelector(
|
|
||||||
'[data-plugin-spread-filter="type"]'
|
|
||||||
) as HTMLSelectElement,
|
|
||||||
spreadThresholdInputs: readSpreadThresholdInputs(root),
|
|
||||||
root
|
root
|
||||||
} satisfies PluginToolbarDom;
|
} satisfies PluginToolbarDom;
|
||||||
syncCustomPagesInputVisibility(toolbarDom);
|
syncCustomPagesInputVisibility(toolbarDom);
|
||||||
syncSpreadFilterControlState(toolbarDom);
|
|
||||||
return toolbarDom;
|
return toolbarDom;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -518,51 +280,6 @@ export function readToolbarExportTarget(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readToolbarSpreadFilter(
|
|
||||||
toolbar: PluginToolbarDom
|
|
||||||
): { error?: string; filter?: SpreadThresholdFilter } {
|
|
||||||
const thresholds: SpreadThresholdFilter["thresholds"] = {};
|
|
||||||
|
|
||||||
for (const [key, input] of Object.entries(toolbar.spreadThresholdInputs)) {
|
|
||||||
const trimmedValue = input.value.trim();
|
|
||||||
if (!trimmedValue) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const numericValue = Number(trimmedValue);
|
|
||||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
|
||||||
return {
|
|
||||||
error: "请输入有效筛选阈值"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
thresholds[key as keyof SpreadThresholdFilter["thresholds"]] = numericValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(thresholds).length === 0) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = Number(toolbar.spreadFilterTypeSelect.value) === 2 ? 2 : 1;
|
|
||||||
return {
|
|
||||||
filter: {
|
|
||||||
config: {
|
|
||||||
flowType:
|
|
||||||
type === 1
|
|
||||||
? 0
|
|
||||||
: Number(toolbar.spreadFilterFlowTypeSelect.value) === 1
|
|
||||||
? 1
|
|
||||||
: 0,
|
|
||||||
onlyAssign:
|
|
||||||
type === 1 ? false : toolbar.spreadFilterOnlyAssignSelect.value === "true",
|
|
||||||
range: Number(toolbar.spreadFilterRangeSelect.value) === 3 ? 3 : 2,
|
|
||||||
type
|
|
||||||
},
|
|
||||||
thresholds
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setToolbarBusyState(
|
export function setToolbarBusyState(
|
||||||
toolbar: PluginToolbarDom,
|
toolbar: PluginToolbarDom,
|
||||||
isBusy: boolean
|
isBusy: boolean
|
||||||
@ -574,18 +291,10 @@ export function setToolbarBusyState(
|
|||||||
toolbar.audienceProfileExportButton,
|
toolbar.audienceProfileExportButton,
|
||||||
toolbar.exportButton,
|
toolbar.exportButton,
|
||||||
toolbar.exportRangeSelect,
|
toolbar.exportRangeSelect,
|
||||||
toolbar.exportCustomPagesInput,
|
toolbar.exportCustomPagesInput
|
||||||
toolbar.spreadFilterTypeSelect,
|
|
||||||
toolbar.spreadFilterOnlyAssignSelect,
|
|
||||||
toolbar.spreadFilterFlowTypeSelect,
|
|
||||||
toolbar.spreadFilterRangeSelect,
|
|
||||||
...Object.values(toolbar.spreadThresholdInputs)
|
|
||||||
].forEach((element) => {
|
].forEach((element) => {
|
||||||
element.disabled = isBusy;
|
element.disabled = isBusy;
|
||||||
});
|
});
|
||||||
if (!isBusy) {
|
|
||||||
syncSpreadFilterControlState(toolbar);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setToolbarExportStatus(
|
export function setToolbarExportStatus(
|
||||||
@ -600,16 +309,6 @@ function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
|
|||||||
toolbar.exportCustomPagesInput.hidden = true;
|
toolbar.exportCustomPagesInput.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncSpreadFilterControlState(toolbar: PluginToolbarDom): void {
|
|
||||||
const isPersonalVideo = toolbar.spreadFilterTypeSelect.value !== "2";
|
|
||||||
if (isPersonalVideo) {
|
|
||||||
toolbar.spreadFilterOnlyAssignSelect.value = "false";
|
|
||||||
toolbar.spreadFilterFlowTypeSelect.value = "0";
|
|
||||||
}
|
|
||||||
toolbar.spreadFilterOnlyAssignSelect.disabled = isPersonalVideo;
|
|
||||||
toolbar.spreadFilterFlowTypeSelect.disabled = isPersonalVideo;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureToolbarMounted(root: HTMLElement, document: Document): void {
|
function ensureToolbarMounted(root: HTMLElement, document: Document): void {
|
||||||
const actionRow = findNativeActionRow(document);
|
const actionRow = findNativeActionRow(document);
|
||||||
if (!actionRow) {
|
if (!actionRow) {
|
||||||
@ -768,8 +467,8 @@ function findNativeActionButton(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const candidates = Array.from(root.querySelectorAll("button")).filter(
|
const candidates = Array.from(root.querySelectorAll("button")).filter(
|
||||||
(element): element is HTMLButtonElement =>
|
(element): element is HTMLElement =>
|
||||||
element instanceof document.defaultView!.HTMLButtonElement
|
element instanceof document.defaultView!.HTMLElement
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
candidates.find((element) => normalizeText(element.textContent) === text) ?? null
|
candidates.find((element) => normalizeText(element.textContent) === text) ?? null
|
||||||
@ -779,72 +478,8 @@ function findNativeActionButton(
|
|||||||
function applyToolbarRootStyles(root: HTMLElement): void {
|
function applyToolbarRootStyles(root: HTMLElement): void {
|
||||||
root.style.display = "inline-flex";
|
root.style.display = "inline-flex";
|
||||||
root.style.alignItems = "center";
|
root.style.alignItems = "center";
|
||||||
root.style.columnGap = "10px";
|
root.style.columnGap = "8px";
|
||||||
root.style.flex = "1 1 auto";
|
root.style.flexWrap = "wrap";
|
||||||
root.style.minWidth = "0";
|
|
||||||
root.style.flexWrap = "nowrap";
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyToolbarPanelStyles(panel: HTMLElement): void {
|
|
||||||
panel.style.display = "flex";
|
|
||||||
panel.style.flexDirection = "column";
|
|
||||||
panel.style.alignItems = "center";
|
|
||||||
panel.style.gap = "6px";
|
|
||||||
panel.style.flex = "1 1 auto";
|
|
||||||
panel.style.minWidth = "0";
|
|
||||||
panel.style.padding = "0";
|
|
||||||
panel.style.overflowX = "hidden";
|
|
||||||
panel.style.overflowY = "hidden";
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyToolbarRowStyles(row: HTMLElement): void {
|
|
||||||
row.style.display = "flex";
|
|
||||||
row.style.alignItems = "center";
|
|
||||||
row.style.justifyContent = "flex-start";
|
|
||||||
row.style.gap = "6px";
|
|
||||||
row.style.minHeight = "32px";
|
|
||||||
row.style.minWidth = "0";
|
|
||||||
row.style.width = "100%";
|
|
||||||
row.style.flexWrap = "nowrap";
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyToolbarGroupStyles(group: HTMLElement): void {
|
|
||||||
group.style.display = "flex";
|
|
||||||
group.style.alignItems = "center";
|
|
||||||
group.style.gap = "8px";
|
|
||||||
group.style.minWidth = "0";
|
|
||||||
group.style.flex = "0 0 auto";
|
|
||||||
group.style.flexWrap = "nowrap";
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyThresholdGroupStyles(group: HTMLElement): void {
|
|
||||||
group.style.display = "flex";
|
|
||||||
group.style.alignItems = "center";
|
|
||||||
group.style.gap = "7px";
|
|
||||||
group.style.minWidth = "0";
|
|
||||||
group.style.flex = "1 1 auto";
|
|
||||||
group.style.flexWrap = "nowrap";
|
|
||||||
group.style.overflowX = "auto";
|
|
||||||
group.style.overflowY = "hidden";
|
|
||||||
}
|
|
||||||
|
|
||||||
function createToolbarGroupTitle(document: Document, label: string): HTMLElement {
|
|
||||||
const title = document.createElement("span");
|
|
||||||
title.dataset.pluginToolbarTitle = label;
|
|
||||||
title.textContent = label;
|
|
||||||
title.style.display = "flex";
|
|
||||||
title.style.alignItems = "center";
|
|
||||||
title.style.height = "32px";
|
|
||||||
title.style.padding = "0 10px";
|
|
||||||
title.style.border = "1px solid #cfe0ff";
|
|
||||||
title.style.borderRadius = "8px";
|
|
||||||
title.style.background = "#eef5ff";
|
|
||||||
title.style.color = "#2563eb";
|
|
||||||
title.style.fontSize = "12px";
|
|
||||||
title.style.fontWeight = "900";
|
|
||||||
title.style.flex = "0 0 auto";
|
|
||||||
title.style.whiteSpace = "nowrap";
|
|
||||||
return title;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyNativeControlStyles(
|
function applyNativeControlStyles(
|
||||||
@ -857,14 +492,7 @@ function applyNativeControlStyles(
|
|||||||
exportButton: HTMLButtonElement;
|
exportButton: HTMLButtonElement;
|
||||||
exportCustomPagesInput: HTMLInputElement;
|
exportCustomPagesInput: HTMLInputElement;
|
||||||
exportRangeSelect: HTMLSelectElement;
|
exportRangeSelect: HTMLSelectElement;
|
||||||
spreadFilterFlowTypeSelect: HTMLSelectElement;
|
}
|
||||||
spreadFilterOnlyAssignSelect: HTMLSelectElement;
|
|
||||||
spreadFilterRangeSelect: HTMLSelectElement;
|
|
||||||
spreadFilterTypeSelect: HTMLSelectElement;
|
|
||||||
} & Record<
|
|
||||||
keyof SpreadThresholdFilter["thresholds"],
|
|
||||||
HTMLInputElement
|
|
||||||
>
|
|
||||||
): void {
|
): void {
|
||||||
const primaryButton =
|
const primaryButton =
|
||||||
findButtonContainingText(document, "发布任务") ??
|
findButtonContainingText(document, "发布任务") ??
|
||||||
@ -891,51 +519,18 @@ function applyNativeControlStyles(
|
|||||||
button.style.whiteSpace = "nowrap";
|
button.style.whiteSpace = "nowrap";
|
||||||
});
|
});
|
||||||
|
|
||||||
const nativeControls = Array.from(Object.values(controls)).filter(
|
[controls.exportRangeSelect, controls.exportCustomPagesInput].forEach((element) => {
|
||||||
(element): element is HTMLInputElement | HTMLSelectElement =>
|
|
||||||
element instanceof document.defaultView!.HTMLInputElement ||
|
|
||||||
element instanceof document.defaultView!.HTMLSelectElement
|
|
||||||
);
|
|
||||||
|
|
||||||
nativeControls.forEach((element) => {
|
|
||||||
element.style.height = "32px";
|
element.style.height = "32px";
|
||||||
element.style.border = "1px solid #d0d7de";
|
element.style.border = "1px solid #d0d7de";
|
||||||
element.style.borderRadius = "6px";
|
element.style.borderRadius = "6px";
|
||||||
element.style.padding = "0 8px";
|
element.style.padding = "0 10px";
|
||||||
element.style.background = "#fff";
|
element.style.background = "#fff";
|
||||||
element.style.color = "#1f2329";
|
element.style.color = "#1f2329";
|
||||||
element.style.boxSizing = "border-box";
|
element.style.boxSizing = "border-box";
|
||||||
element.style.flex = "0 0 auto";
|
|
||||||
});
|
});
|
||||||
|
|
||||||
controls.exportRangeSelect.style.minWidth = "104px";
|
controls.exportRangeSelect.style.minWidth = "104px";
|
||||||
controls.exportCustomPagesInput.style.width = "72px";
|
controls.exportCustomPagesInput.style.width = "72px";
|
||||||
|
|
||||||
[
|
|
||||||
controls.spreadFilterTypeSelect,
|
|
||||||
controls.spreadFilterOnlyAssignSelect,
|
|
||||||
controls.spreadFilterFlowTypeSelect,
|
|
||||||
controls.spreadFilterRangeSelect
|
|
||||||
].forEach((select) => {
|
|
||||||
select.style.minWidth = "84px";
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.values(controls).forEach((element) => {
|
|
||||||
if (
|
|
||||||
element instanceof document.defaultView!.HTMLInputElement &&
|
|
||||||
element.dataset.pluginSpreadThreshold
|
|
||||||
) {
|
|
||||||
element.style.width =
|
|
||||||
element.dataset.pluginSpreadThreshold === "playMedian" ? "82px" : "58px";
|
|
||||||
element.style.minWidth = "0";
|
|
||||||
element.style.height = "26px";
|
|
||||||
element.style.border = "0";
|
|
||||||
element.style.borderRadius = "0";
|
|
||||||
element.style.padding = "0";
|
|
||||||
element.style.outline = "0";
|
|
||||||
element.style.fontWeight = "700";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPrimaryButtonStyles(
|
function applyPrimaryButtonStyles(
|
||||||
@ -953,35 +548,11 @@ function applyPrimaryButtonStyles(
|
|||||||
"background-color 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease";
|
"background-color 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease";
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySpreadThresholdControlStyles(control: HTMLElement): void {
|
|
||||||
control.style.display = "grid";
|
|
||||||
control.style.gridTemplateColumns = "auto auto 1fr auto";
|
|
||||||
control.style.alignItems = "center";
|
|
||||||
control.style.gap = "6px";
|
|
||||||
control.style.height = "32px";
|
|
||||||
control.style.padding = "0 8px";
|
|
||||||
control.style.border = "1px solid #dbe2ec";
|
|
||||||
control.style.borderRadius = "8px";
|
|
||||||
control.style.background = "#ffffff";
|
|
||||||
control.style.flex = "0 0 auto";
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySpreadThresholdConjunctionStyles(conjunction: HTMLElement): void {
|
|
||||||
conjunction.style.color = "#0f8a5f";
|
|
||||||
conjunction.style.fontSize = "12px";
|
|
||||||
conjunction.style.fontWeight = "900";
|
|
||||||
conjunction.style.whiteSpace = "nowrap";
|
|
||||||
conjunction.style.flex = "0 0 auto";
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyStatusStyles(statusText: HTMLElement): void {
|
function applyStatusStyles(statusText: HTMLElement): void {
|
||||||
statusText.style.color = "#64748b";
|
statusText.style.color = "#64748b";
|
||||||
statusText.style.fontSize = "12px";
|
statusText.style.fontSize = "12px";
|
||||||
statusText.style.lineHeight = "20px";
|
statusText.style.lineHeight = "20px";
|
||||||
statusText.style.marginLeft = "0";
|
statusText.style.marginLeft = "4px";
|
||||||
statusText.style.flex = "1 1 auto";
|
|
||||||
statusText.style.minWidth = "120px";
|
|
||||||
statusText.style.textAlign = "center";
|
|
||||||
statusText.style.whiteSpace = "nowrap";
|
statusText.style.whiteSpace = "nowrap";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1030,19 +601,6 @@ function ensurePluginActionButtonTheme(document: Document): void {
|
|||||||
transform: none !important;
|
transform: none !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-plugin-spread-threshold-control] span,
|
|
||||||
[data-plugin-spread-threshold-control] b {
|
|
||||||
color: #667085 !important;
|
|
||||||
font-size: 12px !important;
|
|
||||||
font-weight: 700 !important;
|
|
||||||
white-space: nowrap !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-plugin-spread-threshold-control] b {
|
|
||||||
color: #0f8a5f !important;
|
|
||||||
font-weight: 900 !important;
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
}
|
}
|
||||||
@ -1061,8 +619,8 @@ function findButtonContainingText(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const candidates = Array.from(root.querySelectorAll("button")).filter(
|
const candidates = Array.from(root.querySelectorAll("button")).filter(
|
||||||
(element): element is HTMLButtonElement =>
|
(element): element is HTMLElement =>
|
||||||
element instanceof document.defaultView!.HTMLButtonElement
|
element instanceof document.defaultView!.HTMLElement
|
||||||
);
|
);
|
||||||
|
|
||||||
return candidates.find((element) => normalizeText(element.textContent).includes(text)) ?? null;
|
return candidates.find((element) => normalizeText(element.textContent).includes(text)) ?? null;
|
||||||
|
|||||||
@ -2,8 +2,7 @@ import type {
|
|||||||
BackendMetrics,
|
BackendMetrics,
|
||||||
MarketApiFailureReason,
|
MarketApiFailureReason,
|
||||||
MarketRecord,
|
MarketRecord,
|
||||||
MarketRowSnapshot,
|
MarketRowSnapshot
|
||||||
SpreadInfoMetrics
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import type { AfterSearchRates } from "./types";
|
import type { AfterSearchRates } from "./types";
|
||||||
|
|
||||||
@ -47,13 +46,6 @@ export function createMarketResultStore() {
|
|||||||
...backendMetrics
|
...backendMetrics
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
setSpreadMetricsSuccess(authorId: string, spreadMetrics: SpreadInfoMetrics) {
|
|
||||||
const existingRecord = ensureRecord(authorId);
|
|
||||||
existingRecord.spreadMetrics = {
|
|
||||||
...existingRecord.spreadMetrics,
|
|
||||||
...spreadMetrics
|
|
||||||
};
|
|
||||||
},
|
|
||||||
setAuthorSuccess(authorId: string, rates: AfterSearchRates) {
|
setAuthorSuccess(authorId: string, rates: AfterSearchRates) {
|
||||||
const existingRecord = ensureRecord(authorId);
|
const existingRecord = ensureRecord(authorId);
|
||||||
existingRecord.status = "success";
|
existingRecord.status = "success";
|
||||||
@ -81,10 +73,6 @@ export function createMarketResultStore() {
|
|||||||
existingRecord.price21To60s,
|
existingRecord.price21To60s,
|
||||||
row.price21To60s
|
row.price21To60s
|
||||||
);
|
);
|
||||||
existingRecord.spreadAuthorId = mergeStringValue(
|
|
||||||
existingRecord.spreadAuthorId,
|
|
||||||
row.spreadAuthorId
|
|
||||||
);
|
|
||||||
existingRecord.exportFields = mergeFieldMap(
|
existingRecord.exportFields = mergeFieldMap(
|
||||||
existingRecord.exportFields,
|
existingRecord.exportFields,
|
||||||
row.exportFields
|
row.exportFields
|
||||||
@ -93,10 +81,6 @@ export function createMarketResultStore() {
|
|||||||
existingRecord.backendMetrics,
|
existingRecord.backendMetrics,
|
||||||
row.backendMetrics
|
row.backendMetrics
|
||||||
);
|
);
|
||||||
existingRecord.spreadMetrics = mergeFieldMap(
|
|
||||||
existingRecord.spreadMetrics,
|
|
||||||
row.spreadMetrics
|
|
||||||
);
|
|
||||||
existingRecord.hasDirectRatesSource =
|
existingRecord.hasDirectRatesSource =
|
||||||
existingRecord.hasDirectRatesSource || row.hasDirectRatesSource;
|
existingRecord.hasDirectRatesSource || row.hasDirectRatesSource;
|
||||||
existingRecord.rates = mergeFieldMap(existingRecord.rates, row.rates);
|
existingRecord.rates = mergeFieldMap(existingRecord.rates, row.rates);
|
||||||
|
|||||||
@ -1,349 +0,0 @@
|
|||||||
import type { SpreadInfoMetrics, SpreadMetricThresholds } from "./types";
|
|
||||||
|
|
||||||
interface FetchResponseLike {
|
|
||||||
json(): Promise<unknown>;
|
|
||||||
ok: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FetchLike = (
|
|
||||||
input: string,
|
|
||||||
init?: RequestInit
|
|
||||||
) => Promise<FetchResponseLike>;
|
|
||||||
|
|
||||||
export interface SpreadInfoConfig {
|
|
||||||
flowType: 0 | 1;
|
|
||||||
onlyAssign: boolean;
|
|
||||||
range: 2 | 3;
|
|
||||||
type: 1 | 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpreadInfoClientOptions {
|
|
||||||
baseUrl?: string;
|
|
||||||
configs?: SpreadInfoConfig[];
|
|
||||||
fetchImpl?: FetchLike;
|
|
||||||
timeoutMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpreadInfoMetricDefinition {
|
|
||||||
key: keyof MappedSpreadInfoResponse;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MappedSpreadInfoResponse {
|
|
||||||
averageCommentCount?: string;
|
|
||||||
averageDuration?: string;
|
|
||||||
averageLikeCount?: string;
|
|
||||||
averageShareCount?: string;
|
|
||||||
finishRate?: string;
|
|
||||||
interactionRate?: string;
|
|
||||||
playMedian?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SPREAD_INFO_METRICS: SpreadInfoMetricDefinition[] = [
|
|
||||||
{
|
|
||||||
key: "finishRate",
|
|
||||||
label: "完播率"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "playMedian",
|
|
||||||
label: "播放量中位数"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "interactionRate",
|
|
||||||
label: "互动率"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "averageDuration",
|
|
||||||
label: "作品平均时长"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "averageCommentCount",
|
|
||||||
label: "作品平均评论数"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "averageLikeCount",
|
|
||||||
label: "作品平均点赞数"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "averageShareCount",
|
|
||||||
label: "作品平均转发数"
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const DEFAULT_SPREAD_INFO_CONFIGS: SpreadInfoConfig[] = [
|
|
||||||
{
|
|
||||||
flowType: 0,
|
|
||||||
onlyAssign: false,
|
|
||||||
range: 2,
|
|
||||||
type: 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
flowType: 0,
|
|
||||||
onlyAssign: false,
|
|
||||||
range: 3,
|
|
||||||
type: 1
|
|
||||||
},
|
|
||||||
...buildXingtuVideoConfigs()
|
|
||||||
];
|
|
||||||
|
|
||||||
export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
|
|
||||||
const baseUrl = options.baseUrl ?? resolveBaseUrl();
|
|
||||||
const configs = options.configs ?? DEFAULT_SPREAD_INFO_CONFIGS;
|
|
||||||
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
|
||||||
const timeoutMs = options.timeoutMs ?? 8000;
|
|
||||||
|
|
||||||
return {
|
|
||||||
async loadAuthorSpreadMetrics(authorId: string): Promise<SpreadInfoMetrics> {
|
|
||||||
const metrics: SpreadInfoMetrics = {};
|
|
||||||
|
|
||||||
for (const config of configs) {
|
|
||||||
const mappedResponse = await loadSpreadInfoFromUrl(
|
|
||||||
buildSpreadInfoUrl(authorId, config, baseUrl)
|
|
||||||
);
|
|
||||||
Object.entries(buildSpreadInfoMetricMap(config, mappedResponse)).forEach(
|
|
||||||
([header, value]) => {
|
|
||||||
metrics[header] = value;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return metrics;
|
|
||||||
},
|
|
||||||
async loadAuthorSpreadMetricSnapshot(
|
|
||||||
authorId: string,
|
|
||||||
config: SpreadInfoConfig
|
|
||||||
): Promise<MappedSpreadInfoResponse> {
|
|
||||||
return loadSpreadInfoFromUrl(buildSpreadInfoUrl(authorId, config, baseUrl));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
async function loadSpreadInfoFromUrl(
|
|
||||||
url: string
|
|
||||||
): Promise<MappedSpreadInfoResponse> {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetchImpl(url, {
|
|
||||||
credentials: "include",
|
|
||||||
method: "GET",
|
|
||||||
signal: controller.signal
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapSpreadInfoResponse(await response.json());
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSpreadInfoUrl(
|
|
||||||
authorId: string,
|
|
||||||
config: SpreadInfoConfig,
|
|
||||||
baseUrl: string
|
|
||||||
): string {
|
|
||||||
const url = new URL("/gw/api/data_sp/get_author_spread_info", baseUrl);
|
|
||||||
url.searchParams.set("o_author_id", authorId);
|
|
||||||
url.searchParams.set("platform_source", "1");
|
|
||||||
url.searchParams.set("platform_channel", "1");
|
|
||||||
url.searchParams.set("type", String(config.type));
|
|
||||||
url.searchParams.set("flow_type", String(config.flowType));
|
|
||||||
url.searchParams.set("only_assign", String(config.onlyAssign));
|
|
||||||
url.searchParams.set("range", String(config.range));
|
|
||||||
return url.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSpreadInfoColumns(
|
|
||||||
configs: SpreadInfoConfig[] = DEFAULT_SPREAD_INFO_CONFIGS
|
|
||||||
): string[] {
|
|
||||||
return configs.flatMap((config) =>
|
|
||||||
SPREAD_INFO_METRICS.map((metric) => buildSpreadInfoColumnHeader(config, metric))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSpreadInfoMetricMap(
|
|
||||||
config: SpreadInfoConfig,
|
|
||||||
metrics: MappedSpreadInfoResponse
|
|
||||||
): SpreadInfoMetrics {
|
|
||||||
const values: SpreadInfoMetrics = {};
|
|
||||||
|
|
||||||
SPREAD_INFO_METRICS.forEach((metric) => {
|
|
||||||
const value = metrics[metric.key];
|
|
||||||
if (hasTextValue(value)) {
|
|
||||||
values[buildSpreadInfoColumnHeader(config, metric)] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapSpreadInfoResponse(
|
|
||||||
payload: unknown
|
|
||||||
): MappedSpreadInfoResponse {
|
|
||||||
const data = getPayloadData(payload);
|
|
||||||
if (!data) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
averageCommentCount: readStringLike(data.comment_avg),
|
|
||||||
averageDuration: formatMillisecondsAsSeconds(readNumberLike(data.avg_duration)),
|
|
||||||
averageLikeCount: readStringLike(data.like_avg),
|
|
||||||
averageShareCount: readStringLike(data.share_avg),
|
|
||||||
finishRate: formatBasisPointPercent(
|
|
||||||
readNumberLike(readNestedValue(data.play_over_rate, "value"))
|
|
||||||
),
|
|
||||||
interactionRate: formatBasisPointPercent(
|
|
||||||
readNumberLike(readNestedValue(data.interact_rate, "value"))
|
|
||||||
),
|
|
||||||
playMedian:
|
|
||||||
readStringLike(data.play_mid) ??
|
|
||||||
readStringLike(readNestedValue(readNestedValue(data.item_rate, "play_mid"), "value"))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function matchesSpreadThresholds(
|
|
||||||
metrics: MappedSpreadInfoResponse,
|
|
||||||
thresholds: SpreadMetricThresholds
|
|
||||||
): boolean {
|
|
||||||
return Object.entries(thresholds).every(([key, threshold]) => {
|
|
||||||
if (typeof threshold !== "number" || !Number.isFinite(threshold)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const metricValue = metrics[key as keyof SpreadMetricThresholds];
|
|
||||||
const numericValue = readDisplayNumber(metricValue);
|
|
||||||
return numericValue !== null && numericValue >= threshold;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSpreadInfoColumnHeader(
|
|
||||||
config: SpreadInfoConfig,
|
|
||||||
metric: SpreadInfoMetricDefinition
|
|
||||||
): string {
|
|
||||||
return ["内容数据", ...buildConfigPrefixParts(config), metric.label].join("-");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildConfigPrefixParts(config: SpreadInfoConfig): string[] {
|
|
||||||
const typeLabel = config.type === 1 ? "个人视频" : "星图视频";
|
|
||||||
const rangeLabel = config.range === 2 ? "近30天" : "近90天";
|
|
||||||
|
|
||||||
if (config.type === 1) {
|
|
||||||
return [typeLabel, rangeLabel];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
config.onlyAssign ? "只看指派" : "不限指派",
|
|
||||||
config.flowType === 1 ? "排除营销流量" : "不排除营销流量",
|
|
||||||
typeLabel,
|
|
||||||
rangeLabel
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildXingtuVideoConfigs(): SpreadInfoConfig[] {
|
|
||||||
const configs: SpreadInfoConfig[] = [];
|
|
||||||
[false, true].forEach((onlyAssign) => {
|
|
||||||
([0, 1] as const).forEach((flowType) => {
|
|
||||||
([2, 3] as const).forEach((range) => {
|
|
||||||
configs.push({
|
|
||||||
flowType,
|
|
||||||
onlyAssign,
|
|
||||||
range,
|
|
||||||
type: 2
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return configs;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPayloadData(payload: unknown): Record<string, unknown> | null {
|
|
||||||
if (!isRecord(payload)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isRecord(payload.data) ? payload.data : payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readNestedValue(value: unknown, key: string): unknown {
|
|
||||||
return isRecord(value) ? value[key] : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readStringLike(value: unknown): string | undefined {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "number") {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readNumberLike(value: unknown): number | null {
|
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === "string" && value.trim().length > 0) {
|
|
||||||
const parsedValue = Number(value);
|
|
||||||
return Number.isFinite(parsedValue) ? parsedValue : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readDisplayNumber(value: string | undefined): number | null {
|
|
||||||
if (!hasTextValue(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedValue = Number(value.replace(/[% ,]/g, ""));
|
|
||||||
return Number.isFinite(parsedValue) ? parsedValue : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBasisPointPercent(value: number | null): string | undefined {
|
|
||||||
if (value === null) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${formatDecimal(value / 100)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMillisecondsAsSeconds(value: number | null): string | undefined {
|
|
||||||
if (value === null) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatDecimal(value / 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDecimal(value: number): string {
|
|
||||||
return value.toFixed(2).replace(/\.?0+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveBaseUrl(): string {
|
|
||||||
if (typeof location !== "undefined" && location.origin) {
|
|
||||||
return location.origin;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "https://www.xingtu.cn";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function defaultFetch(input: string, init?: RequestInit) {
|
|
||||||
return fetch(input, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasTextValue(value: string | undefined): value is string {
|
|
||||||
return typeof value === "string" && value.trim().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null;
|
|
||||||
}
|
|
||||||
@ -12,28 +12,6 @@ export interface BackendMetrics {
|
|||||||
newA3Rate?: string;
|
newA3Rate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SpreadInfoMetrics = Record<string, string>;
|
|
||||||
|
|
||||||
export interface SpreadMetricThresholds {
|
|
||||||
averageCommentCount?: number;
|
|
||||||
averageDuration?: number;
|
|
||||||
averageLikeCount?: number;
|
|
||||||
averageShareCount?: number;
|
|
||||||
finishRate?: number;
|
|
||||||
interactionRate?: number;
|
|
||||||
playMedian?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SpreadThresholdFilter {
|
|
||||||
config: {
|
|
||||||
flowType: 0 | 1;
|
|
||||||
onlyAssign: boolean;
|
|
||||||
range: 2 | 3;
|
|
||||||
type: 1 | 2;
|
|
||||||
};
|
|
||||||
thresholds: SpreadMetricThresholds;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MarketSortField =
|
export type MarketSortField =
|
||||||
| keyof Required<AfterSearchRates>
|
| keyof Required<AfterSearchRates>
|
||||||
| keyof Required<BackendMetrics>;
|
| keyof Required<BackendMetrics>;
|
||||||
@ -50,8 +28,6 @@ export interface MarketRowSnapshot {
|
|||||||
location?: string;
|
location?: string;
|
||||||
price21To60s?: string;
|
price21To60s?: string;
|
||||||
rates?: AfterSearchRates;
|
rates?: AfterSearchRates;
|
||||||
spreadAuthorId?: string;
|
|
||||||
spreadMetrics?: SpreadInfoMetrics;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MarketRecord extends MarketRowSnapshot {
|
export interface MarketRecord extends MarketRowSnapshot {
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export const DEFAULT_BATCH_SUBMIT_BASE_URL = "http://localhost:8083";
|
export const DEFAULT_BATCH_SUBMIT_BASE_URL = "http://192.168.31.21:8083";
|
||||||
|
|||||||
@ -54,7 +54,29 @@ describe("audience-profile-csv", () => {
|
|||||||
hotRate: "缺失"
|
hotRate: "缺失"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
status: "success"
|
status: "success",
|
||||||
|
videos: {
|
||||||
|
personalVideo: {
|
||||||
|
averageComment: "4.5w",
|
||||||
|
averageDuration: "150s",
|
||||||
|
averageLike: "113.2w",
|
||||||
|
averageShare: "26.5w",
|
||||||
|
finishRate: "15.8%",
|
||||||
|
interactionRate: "3.9%",
|
||||||
|
medianPlay: "3738.4w",
|
||||||
|
publishedItems: "<5"
|
||||||
|
},
|
||||||
|
xingtuVideo: {
|
||||||
|
averageComment: "5.1w",
|
||||||
|
averageDuration: "170s",
|
||||||
|
averageLike: "150.3w",
|
||||||
|
averageShare: "68.4w",
|
||||||
|
finishRate: "19.9%",
|
||||||
|
interactionRate: "5.5%",
|
||||||
|
medianPlay: "4059.7w",
|
||||||
|
publishedItems: "<5"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
record: {
|
record: {
|
||||||
authorId: "123",
|
authorId: "123",
|
||||||
@ -63,10 +85,6 @@ describe("audience-profile-csv", () => {
|
|||||||
达人信息: "达人 A",
|
达人信息: "达人 A",
|
||||||
连接用户数: "300w"
|
连接用户数: "300w"
|
||||||
},
|
},
|
||||||
spreadMetrics: {
|
|
||||||
"内容数据-个人视频-近30天-播放量中位数": "10913233",
|
|
||||||
"内容数据-只看指派-不排除营销流量-星图视频-近90天-作品平均评论数": "7502"
|
|
||||||
},
|
|
||||||
status: "success"
|
status: "success"
|
||||||
}
|
}
|
||||||
} satisfies AudienceProfileExportRow
|
} satisfies AudienceProfileExportRow
|
||||||
@ -77,10 +95,8 @@ describe("audience-profile-csv", () => {
|
|||||||
expect(headerLine).toContain("达人信息,连接用户数");
|
expect(headerLine).toContain("达人信息,连接用户数");
|
||||||
expect(headerLine).not.toContain("抓取状态");
|
expect(headerLine).not.toContain("抓取状态");
|
||||||
expect(headerLine).not.toContain("失败原因");
|
expect(headerLine).not.toContain("失败原因");
|
||||||
expect(headerLine).toContain("内容数据-个人视频-近30天-播放量中位数");
|
expect(headerLine).toContain("内容数据-个人视频-播放量中位数");
|
||||||
expect(headerLine).toContain(
|
expect(headerLine).toContain("内容数据-星图视频-平均转发");
|
||||||
"内容数据-只看指派-不排除营销流量-星图视频-近90天-作品平均评论数"
|
|
||||||
);
|
|
||||||
expect(headerLine).toContain("效果预估-1-20s视频-预期CPM");
|
expect(headerLine).toContain("效果预估-1-20s视频-预期CPM");
|
||||||
expect(headerLine).toContain("效果预估-20-60s视频-爆文率");
|
expect(headerLine).toContain("效果预估-20-60s视频-爆文率");
|
||||||
expect(headerLine).toContain("效果预估-60s以上视频-预期播放量");
|
expect(headerLine).toContain("效果预估-60s以上视频-预期播放量");
|
||||||
@ -100,15 +116,8 @@ describe("audience-profile-csv", () => {
|
|||||||
expect(headerLine).not.toContain("兴趣TOP");
|
expect(headerLine).not.toContain("兴趣TOP");
|
||||||
expect(rowLine).toContain("71.7%");
|
expect(rowLine).toContain("71.7%");
|
||||||
expect(rowLine).toContain("60%");
|
expect(rowLine).toContain("60%");
|
||||||
expect(readCsvValue(csv, "内容数据-个人视频-近30天-播放量中位数")).toBe(
|
expect(readCsvValue(csv, "内容数据-个人视频-播放量中位数")).toBe("3738.4w");
|
||||||
"10913233"
|
expect(readCsvValue(csv, "内容数据-星图视频-平均转发")).toBe("68.4w");
|
||||||
);
|
|
||||||
expect(
|
|
||||||
readCsvValue(
|
|
||||||
csv,
|
|
||||||
"内容数据-只看指派-不排除营销流量-星图视频-近90天-作品平均评论数"
|
|
||||||
)
|
|
||||||
).toBe("7502");
|
|
||||||
expect(readCsvValue(csv, "效果预估-1-20s视频-预期CPM")).toBe("120.0");
|
expect(readCsvValue(csv, "效果预估-1-20s视频-预期CPM")).toBe("120.0");
|
||||||
expect(readCsvValue(csv, "效果预估-20-60s视频-爆文率")).toBe("缺失");
|
expect(readCsvValue(csv, "效果预估-20-60s视频-爆文率")).toBe("缺失");
|
||||||
});
|
});
|
||||||
@ -193,7 +202,7 @@ describe("audience-profile-csv", () => {
|
|||||||
const row = buildSuccessRow();
|
const row = buildSuccessRow();
|
||||||
const csv = buildAudienceProfileCsv([row], {
|
const csv = buildAudienceProfileCsv([row], {
|
||||||
selectedHeaders: [
|
selectedHeaders: [
|
||||||
"内容数据-个人视频-近30天-播放量中位数",
|
"内容数据-个人视频-播放量中位数",
|
||||||
"观众画像-男性占比"
|
"观众画像-男性占比"
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@ -201,9 +210,9 @@ describe("audience-profile-csv", () => {
|
|||||||
const [headerLine, rowLine] = csv.split("\n");
|
const [headerLine, rowLine] = csv.split("\n");
|
||||||
|
|
||||||
expect(headerLine).toBe(
|
expect(headerLine).toBe(
|
||||||
"达人信息,连接用户数,内容数据-个人视频-近30天-播放量中位数,观众画像-男性占比"
|
"达人信息,连接用户数,内容数据-个人视频-播放量中位数,观众画像-男性占比"
|
||||||
);
|
);
|
||||||
expect(rowLine).toBe("达人 A,300w,10913233,71.7%");
|
expect(rowLine).toBe("达人 A,300w,3738.4w,71.7%");
|
||||||
expect(headerLine).not.toContain("秒思api-看后搜数");
|
expect(headerLine).not.toContain("秒思api-看后搜数");
|
||||||
expect(headerLine).not.toContain("粉丝画像-女性占比");
|
expect(headerLine).not.toContain("粉丝画像-女性占比");
|
||||||
});
|
});
|
||||||
@ -218,15 +227,15 @@ describe("audience-profile-csv", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const csv = buildAudienceProfileCsv([row], {
|
const csv = buildAudienceProfileCsv([row], {
|
||||||
selectedHeaders: ["内容数据-个人视频-近30天-播放量中位数"]
|
selectedHeaders: ["内容数据-个人视频-播放量中位数"]
|
||||||
});
|
});
|
||||||
|
|
||||||
const [headerLine, rowLine] = csv.split("\n");
|
const [headerLine, rowLine] = csv.split("\n");
|
||||||
|
|
||||||
expect(headerLine).toBe(
|
expect(headerLine).toBe(
|
||||||
"达人ID,达人名称,导出状态,失败原因,内容数据-个人视频-近30天-播放量中位数"
|
"达人ID,达人名称,导出状态,失败原因,内容数据-个人视频-播放量中位数"
|
||||||
);
|
);
|
||||||
expect(rowLine).toBe("123,达人 A,成功,,10913233");
|
expect(rowLine).toBe("123,达人 A,成功,,3738.4w");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lists headers for field picker defaults", () => {
|
test("lists headers for field picker defaults", () => {
|
||||||
@ -235,7 +244,7 @@ describe("audience-profile-csv", () => {
|
|||||||
"达人信息",
|
"达人信息",
|
||||||
"连接用户数",
|
"连接用户数",
|
||||||
"秒思api-看后搜数",
|
"秒思api-看后搜数",
|
||||||
"内容数据-个人视频-近30天-播放量中位数",
|
"内容数据-个人视频-播放量中位数",
|
||||||
"效果预估-20-60s视频-预期CPM",
|
"效果预估-20-60s视频-预期CPM",
|
||||||
"观众画像-男性占比",
|
"观众画像-男性占比",
|
||||||
"铁粉画像-小镇青年占比"
|
"铁粉画像-小镇青年占比"
|
||||||
@ -251,9 +260,7 @@ describe("audience-profile-csv", () => {
|
|||||||
label: "秒思api数据"
|
label: "秒思api数据"
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: expect.arrayContaining([
|
headers: expect.arrayContaining(["内容数据-个人视频-播放量中位数"]),
|
||||||
"内容数据-只看指派-不排除营销流量-星图视频-近90天-作品平均评论数"
|
|
||||||
]),
|
|
||||||
label: "内容数据"
|
label: "内容数据"
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@ -303,7 +310,12 @@ function buildSuccessRow(
|
|||||||
hotRate: "缺失"
|
hotRate: "缺失"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
status: "success"
|
status: "success",
|
||||||
|
videos: {
|
||||||
|
personalVideo: {
|
||||||
|
medianPlay: "3738.4w"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
record: {
|
record: {
|
||||||
authorId: "123",
|
authorId: "123",
|
||||||
@ -312,10 +324,6 @@ function buildSuccessRow(
|
|||||||
达人信息: "达人 A",
|
达人信息: "达人 A",
|
||||||
连接用户数: "300w"
|
连接用户数: "300w"
|
||||||
},
|
},
|
||||||
spreadMetrics: {
|
|
||||||
"内容数据-个人视频-近30天-播放量中位数": "10913233",
|
|
||||||
"内容数据-只看指派-不排除营销流量-星图视频-近90天-作品平均评论数": "7502"
|
|
||||||
},
|
|
||||||
status: "success",
|
status: "success",
|
||||||
...overrides
|
...overrides
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { createBatchSubmitClient } from "../src/shared/batch-submit-client";
|
|||||||
|
|
||||||
describe("batch-submit-client", () => {
|
describe("batch-submit-client", () => {
|
||||||
test("exports the default batch submit base url", () => {
|
test("exports the default batch submit base url", () => {
|
||||||
expect(DEFAULT_BATCH_SUBMIT_BASE_URL).toBe("http://localhost:8083");
|
expect(DEFAULT_BATCH_SUBMIT_BASE_URL).toBe("http://192.168.31.21:8083");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("posts the batch payload with a Bearer token", async () => {
|
test("posts the batch payload with a Bearer token", async () => {
|
||||||
|
|||||||
@ -2,12 +2,24 @@ import { describe, expect, test, vi } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
buildBusinessAbilityEstimateUrl,
|
buildBusinessAbilityEstimateUrl,
|
||||||
|
buildBusinessAbilityVideoUrl,
|
||||||
createBusinessAbilityClient,
|
createBusinessAbilityClient,
|
||||||
mapBusinessAbilityEstimateResponse
|
mapBusinessAbilityEstimateResponse,
|
||||||
|
mapBusinessAbilityVideoResponse
|
||||||
} from "../src/content/market/business-ability-client";
|
} from "../src/content/market/business-ability-client";
|
||||||
|
|
||||||
describe("business-ability-client", () => {
|
describe("business-ability-client", () => {
|
||||||
test("builds the commerce spread estimate url used by the Xingtu detail page", () => {
|
test("builds commercial ability urls used by the Xingtu detail page", () => {
|
||||||
|
expect(
|
||||||
|
buildBusinessAbilityVideoUrl(
|
||||||
|
"6724241209444794382",
|
||||||
|
"https://www.xingtu.cn",
|
||||||
|
2
|
||||||
|
)
|
||||||
|
).toBe(
|
||||||
|
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=2&flow_type=0&only_assign=true&range=2"
|
||||||
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
buildBusinessAbilityEstimateUrl(
|
buildBusinessAbilityEstimateUrl(
|
||||||
"6724241209444794382",
|
"6724241209444794382",
|
||||||
@ -18,6 +30,19 @@ describe("business-ability-client", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("maps video content metrics into page-style display values", () => {
|
||||||
|
expect(mapBusinessAbilityVideoResponse(buildVideoPayload())).toEqual({
|
||||||
|
averageComment: "5.1w",
|
||||||
|
averageDuration: "170s",
|
||||||
|
averageLike: "150.3w",
|
||||||
|
averageShare: "68.4w",
|
||||||
|
finishRate: "19.9%",
|
||||||
|
interactionRate: "5.5%",
|
||||||
|
medianPlay: "4059.7w",
|
||||||
|
publishedItems: "<5"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("maps duration estimates into page-style display values", () => {
|
test("maps duration estimates into page-style display values", () => {
|
||||||
expect(mapBusinessAbilityEstimateResponse(buildEstimatePayload())).toEqual({
|
expect(mapBusinessAbilityEstimateResponse(buildEstimatePayload())).toEqual({
|
||||||
oneToTwenty: {
|
oneToTwenty: {
|
||||||
@ -73,12 +98,15 @@ describe("business-ability-client", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("loads duration estimate metrics without requesting legacy video content metrics", async () => {
|
test("loads personal video, Xingtu video, and duration estimate metrics", async () => {
|
||||||
const requestedUrls: string[] = [];
|
const requestedUrls: string[] = [];
|
||||||
const fetchImpl = vi.fn(async (input: string) => {
|
const fetchImpl = vi.fn(async (input: string) => {
|
||||||
requestedUrls.push(input);
|
requestedUrls.push(input);
|
||||||
return {
|
return {
|
||||||
json: async () => buildEstimatePayload(),
|
json: async () =>
|
||||||
|
input.includes("get_author_commerce_spread_info")
|
||||||
|
? buildEstimatePayload()
|
||||||
|
: buildVideoPayload(),
|
||||||
ok: true
|
ok: true
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -98,15 +126,35 @@ describe("business-ability-client", () => {
|
|||||||
estimates: expect.objectContaining({
|
estimates: expect.objectContaining({
|
||||||
twentyToSixty: expect.objectContaining({ expectedCpm: "212.0" })
|
twentyToSixty: expect.objectContaining({ expectedCpm: "212.0" })
|
||||||
}),
|
}),
|
||||||
status: "success"
|
status: "success",
|
||||||
|
videos: {
|
||||||
|
personalVideo: expect.objectContaining({ medianPlay: "4059.7w" }),
|
||||||
|
xingtuVideo: expect.objectContaining({ medianPlay: "4059.7w" })
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(requestedUrls).toEqual([
|
expect(requestedUrls).toEqual([
|
||||||
|
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=1&flow_type=0&only_assign=true&range=2",
|
||||||
|
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=2&flow_type=0&only_assign=true&range=2",
|
||||||
"https://www.xingtu.cn/gw/api/aggregator/get_author_commerce_spread_info?o_author_id=6724241209444794382"
|
"https://www.xingtu.cn/gw/api/aggregator/get_author_commerce_spread_info?o_author_id=6724241209444794382"
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function buildVideoPayload() {
|
||||||
|
return {
|
||||||
|
avg_duration: 17002,
|
||||||
|
base_resp: { status_code: 0, status_message: "" },
|
||||||
|
comment_avg: 51404,
|
||||||
|
interact_rate: { value: 551 },
|
||||||
|
item_num: 2,
|
||||||
|
like_avg: 1503028,
|
||||||
|
play_mid: 40596960,
|
||||||
|
play_over_rate: { value: 1991 },
|
||||||
|
share_avg: 684318
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildEstimatePayload() {
|
function buildEstimatePayload() {
|
||||||
return {
|
return {
|
||||||
base_resp: { status_code: 0, status_message: "" },
|
base_resp: { status_code: 0, status_message: "" },
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { buildMarketCsv } from "../src/content/market/csv-exporter";
|
import { buildMarketCsv } from "../src/content/market/csv-exporter";
|
||||||
import { buildSpreadInfoColumns } from "../src/content/market/spread-info";
|
|
||||||
import type { MarketRecord } from "../src/content/market/types";
|
import type { MarketRecord } from "../src/content/market/types";
|
||||||
|
|
||||||
describe("csv-exporter", () => {
|
describe("csv-exporter", () => {
|
||||||
@ -22,13 +21,12 @@ describe("csv-exporter", () => {
|
|||||||
"秒思api-新增A3数",
|
"秒思api-新增A3数",
|
||||||
"秒思api-新增A3率",
|
"秒思api-新增A3率",
|
||||||
"秒思api-CPA3",
|
"秒思api-CPA3",
|
||||||
"秒思api-cp_search",
|
"秒思api-cp_search"
|
||||||
...buildSpreadInfoColumns()
|
|
||||||
].join(",")
|
].join(",")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses page export field order and appends the plugin columns", () => {
|
test("uses page export field order and appends the two plugin columns", () => {
|
||||||
const csv = buildMarketCsv([
|
const csv = buildMarketCsv([
|
||||||
{
|
{
|
||||||
authorId: "123",
|
authorId: "123",
|
||||||
@ -68,16 +66,11 @@ describe("csv-exporter", () => {
|
|||||||
"秒思api-新增A3数",
|
"秒思api-新增A3数",
|
||||||
"秒思api-新增A3率",
|
"秒思api-新增A3率",
|
||||||
"秒思api-CPA3",
|
"秒思api-CPA3",
|
||||||
"秒思api-cp_search",
|
"秒思api-cp_search"
|
||||||
...buildSpreadInfoColumns()
|
|
||||||
].join(",")
|
].join(",")
|
||||||
);
|
);
|
||||||
expect(rowLine).toMatch(
|
expect(rowLine).toBe(
|
||||||
new RegExp(
|
|
||||||
`^${escapeRegExp(
|
|
||||||
'Alice,100w,"¥450,000",0.5% - 1%,1% - 3%,0.36%,"9,689.96","78,366.22",3.44%,1.79,14.46'
|
'Alice,100w,"¥450,000",0.5% - 1%,1% - 3%,0.36%,"9,689.96","78,366.22",3.44%,1.79,14.46'
|
||||||
)}`
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -108,13 +101,10 @@ describe("csv-exporter", () => {
|
|||||||
"秒思api-新增A3数",
|
"秒思api-新增A3数",
|
||||||
"秒思api-新增A3率",
|
"秒思api-新增A3率",
|
||||||
"秒思api-CPA3",
|
"秒思api-CPA3",
|
||||||
"秒思api-cp_search",
|
"秒思api-cp_search"
|
||||||
...buildSpreadInfoColumns()
|
|
||||||
].join(",")
|
].join(",")
|
||||||
);
|
);
|
||||||
expect(rowLine.split(",").slice(0, 10).join(",")).toBe(
|
expect(rowLine).toBe("Alice,100w,,,,,,,,");
|
||||||
"Alice,100w,,,,,,,,"
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("escapes commas and quotes", () => {
|
test("escapes commas and quotes", () => {
|
||||||
@ -147,10 +137,7 @@ describe("csv-exporter", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const [, rowLine] = csv.split("\n");
|
const [, rowLine] = csv.split("\n");
|
||||||
expect(rowLine.split(",").slice(0, 12).join(",")).toBe(
|
expect(rowLine).toBe("123,Alice,,,,,,,,,,");
|
||||||
"123,Alice,,,,,,,,,,"
|
|
||||||
);
|
|
||||||
expect(rowLine.split(",").slice(12).every((cell) => cell === "")).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses normalized display values in export rows", () => {
|
test("uses normalized display values in export rows", () => {
|
||||||
@ -185,53 +172,6 @@ describe("csv-exporter", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const [, rowLine] = csv.split("\n");
|
const [, rowLine] = csv.split("\n");
|
||||||
expect(rowLine.split(",").slice(0, 12).join(",")).toBe(
|
expect(rowLine).toBe("123,Alice,,,0.5% - 1%,0.02% - 0.1%,,,,,,");
|
||||||
"123,Alice,,,0.5% - 1%,0.02% - 0.1%,,,,,,"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("appends spread info metric columns after backend metrics", () => {
|
|
||||||
const csv = buildMarketCsv([
|
|
||||||
{
|
|
||||||
authorId: "123",
|
|
||||||
authorName: "Alice",
|
|
||||||
spreadMetrics: {
|
|
||||||
"内容数据-个人视频-近30天-完播率": "28.24%",
|
|
||||||
"内容数据-只看指派-排除营销流量-星图视频-近30天-互动率": "4.02%"
|
|
||||||
},
|
|
||||||
status: "success"
|
|
||||||
} satisfies MarketRecord
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [headerLine, rowLine] = csv.split("\n");
|
|
||||||
expect(headerLine).toContain(
|
|
||||||
[
|
|
||||||
"秒思api-cp_search",
|
|
||||||
"内容数据-个人视频-近30天-完播率",
|
|
||||||
"内容数据-个人视频-近30天-播放量中位数"
|
|
||||||
].join(",")
|
|
||||||
);
|
|
||||||
expect(headerLine).toContain(
|
|
||||||
"内容数据-只看指派-排除营销流量-星图视频-近30天-互动率"
|
|
||||||
);
|
|
||||||
expect(rowLine).toContain("28.24%");
|
|
||||||
expect(rowLine).toContain("4.02%");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("emits empty spread info cells when spread metrics are absent", () => {
|
|
||||||
const csv = buildMarketCsv([
|
|
||||||
{
|
|
||||||
authorId: "123",
|
|
||||||
authorName: "Alice",
|
|
||||||
status: "success"
|
|
||||||
} satisfies MarketRecord
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [, rowLine] = csv.split("\n");
|
|
||||||
expect(rowLine.split(",").slice(-70).every((cell) => cell === "")).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function escapeRegExp(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|||||||
@ -43,7 +43,7 @@ describe("manifest", () => {
|
|||||||
"https://*.xingtu.cn/ad/creator/market*",
|
"https://*.xingtu.cn/ad/creator/market*",
|
||||||
"https://login-api.intelligrow.cn/*",
|
"https://login-api.intelligrow.cn/*",
|
||||||
"https://talent-search.intelligrow.cn/*",
|
"https://talent-search.intelligrow.cn/*",
|
||||||
"http://localhost:8083/*",
|
"http://192.168.31.21:8083/*",
|
||||||
"https://*/*"
|
"https://*/*"
|
||||||
]);
|
]);
|
||||||
expect(releaseManifest.host_permissions).not.toEqual(
|
expect(releaseManifest.host_permissions).not.toEqual(
|
||||||
|
|||||||
@ -9,12 +9,11 @@ const disposers: Array<() => void> = [];
|
|||||||
|
|
||||||
describe("market-content-entry", () => {
|
describe("market-content-entry", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
installUsableLocalStorage();
|
|
||||||
document.body.innerHTML = "";
|
document.body.innerHTML = "";
|
||||||
document.documentElement.removeAttribute("data-sces-market-rows");
|
document.documentElement.removeAttribute("data-sces-market-rows");
|
||||||
document.documentElement.removeAttribute("data-sces-market-request-snapshot");
|
document.documentElement.removeAttribute("data-sces-market-request-snapshot");
|
||||||
document.documentElement.removeAttribute("data-test-page-index");
|
document.documentElement.removeAttribute("data-test-page-index");
|
||||||
clearLocalStorage();
|
window.localStorage.clear();
|
||||||
window.history.replaceState({}, "", "/");
|
window.history.replaceState({}, "", "/");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -294,10 +293,7 @@ describe("market-content-entry", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("renders the plugin action bar inside the native market action row", async () => {
|
test("renders the plugin action bar inside the native market action row", async () => {
|
||||||
document.body.innerHTML = buildRealMarketFixture([
|
document.body.innerHTML = buildMarketFixture();
|
||||||
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
|
|
||||||
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
|
|
||||||
]);
|
|
||||||
|
|
||||||
const { createMarketController } = await import("../src/content/market/index");
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
const controller = trackController(createMarketController({
|
const controller = trackController(createMarketController({
|
||||||
@ -385,122 +381,6 @@ describe("market-content-entry", () => {
|
|||||||
expect(audienceProfileByIdExportButton?.style.color).toBe("rgb(255, 255, 255)");
|
expect(audienceProfileByIdExportButton?.style.color).toBe("rgb(255, 255, 255)");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders the approved compact toolbar layout from the preview", async () => {
|
|
||||||
document.body.innerHTML = buildMarketFixture();
|
|
||||||
|
|
||||||
const { createMarketController } = await import("../src/content/market/index");
|
|
||||||
const controller = trackController(createMarketController({
|
|
||||||
document,
|
|
||||||
loadAuthorMetrics: async () => ({
|
|
||||||
success: false,
|
|
||||||
reason: "request-failed"
|
|
||||||
}),
|
|
||||||
window
|
|
||||||
}));
|
|
||||||
|
|
||||||
await controller.ready;
|
|
||||||
|
|
||||||
const toolbar = document.querySelector(
|
|
||||||
'[data-plugin-toolbar="root"]'
|
|
||||||
) as HTMLElement | null;
|
|
||||||
const panel = document.querySelector(
|
|
||||||
'[data-plugin-toolbar-panel="root"]'
|
|
||||||
) as HTMLElement | null;
|
|
||||||
const primaryRow = document.querySelector(
|
|
||||||
'[data-plugin-toolbar-row="primary"]'
|
|
||||||
) as HTMLElement | null;
|
|
||||||
const thresholdRow = document.querySelector(
|
|
||||||
'[data-plugin-toolbar-row="thresholds"]'
|
|
||||||
) as HTMLElement | null;
|
|
||||||
const dataGroup = document.querySelector(
|
|
||||||
'[data-plugin-toolbar-group="data"]'
|
|
||||||
) as HTMLElement | null;
|
|
||||||
const videoGroup = document.querySelector(
|
|
||||||
'[data-plugin-toolbar-group="video"]'
|
|
||||||
) as HTMLElement | null;
|
|
||||||
const thresholdGroup = document.querySelector(
|
|
||||||
'[data-plugin-toolbar-group="thresholds"]'
|
|
||||||
) as HTMLElement | null;
|
|
||||||
const statusText = document.querySelector(
|
|
||||||
'[data-plugin-export-status="text"]'
|
|
||||||
) as HTMLElement | null;
|
|
||||||
const titles = Array.from(
|
|
||||||
document.querySelectorAll("[data-plugin-toolbar-title]")
|
|
||||||
) as HTMLElement[];
|
|
||||||
const buttons = Array.from(
|
|
||||||
document.querySelectorAll("[data-plugin-toolbar-group='data'] button")
|
|
||||||
) as HTMLButtonElement[];
|
|
||||||
const operators = Array.from(
|
|
||||||
document.querySelectorAll("[data-plugin-spread-threshold-operator]")
|
|
||||||
).map((element) => element.textContent);
|
|
||||||
const conjunctions = Array.from(
|
|
||||||
document.querySelectorAll("[data-plugin-spread-threshold-conjunction]")
|
|
||||||
).map((element) => element.textContent);
|
|
||||||
const thresholdControls = Array.from(
|
|
||||||
document.querySelectorAll("[data-plugin-spread-threshold-control]")
|
|
||||||
);
|
|
||||||
const thresholdInputs = Array.from(
|
|
||||||
document.querySelectorAll("[data-plugin-spread-threshold]")
|
|
||||||
) as HTMLInputElement[];
|
|
||||||
|
|
||||||
expect(toolbar?.style.flexWrap).toBe("nowrap");
|
|
||||||
expect(panel?.style.flexDirection).toBe("column");
|
|
||||||
expect(panel?.style.alignItems).toBe("center");
|
|
||||||
expect(primaryRow?.style.flexWrap).toBe("nowrap");
|
|
||||||
expect(thresholdRow?.style.flexWrap).toBe("nowrap");
|
|
||||||
expect(thresholdRow?.style.alignItems).toBe("center");
|
|
||||||
expect(dataGroup?.parentElement).toBe(primaryRow);
|
|
||||||
expect(videoGroup?.parentElement).toBe(primaryRow);
|
|
||||||
expect(statusText?.parentElement).toBe(primaryRow);
|
|
||||||
expect(thresholdGroup?.parentElement).toBe(thresholdRow);
|
|
||||||
expect(thresholdGroup?.style.flexWrap).toBe("nowrap");
|
|
||||||
expect(thresholdGroup?.style.overflowX).toBe("auto");
|
|
||||||
expect(primaryRow?.style.justifyContent).toBe("flex-start");
|
|
||||||
expect(thresholdRow?.style.justifyContent).toBe("flex-start");
|
|
||||||
expect(titles.map((element) => element.textContent)).toEqual([
|
|
||||||
"视频口径",
|
|
||||||
"传播指标筛选"
|
|
||||||
]);
|
|
||||||
expect(titles[0]?.style.background).toBe("rgb(238, 245, 255)");
|
|
||||||
expect(titles[1]?.style.background).toBe("rgb(238, 245, 255)");
|
|
||||||
expect(document.querySelector("[data-plugin-spread-threshold-rule]")).toBeNull();
|
|
||||||
expect(operators).toEqual(["≥", "≥", "≥", "≥", "≥", "≥", "≥"]);
|
|
||||||
expect(conjunctions).toEqual(["且", "且", "且", "且", "且", "且"]);
|
|
||||||
expect(thresholdInputs.map((input) => input.placeholder)).toEqual([
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
""
|
|
||||||
]);
|
|
||||||
expect(thresholdInputs.map((input) => input.step)).toEqual([
|
|
||||||
"1",
|
|
||||||
"1",
|
|
||||||
"1",
|
|
||||||
"1",
|
|
||||||
"0.1",
|
|
||||||
"0.1",
|
|
||||||
"1"
|
|
||||||
]);
|
|
||||||
expect(thresholdControls.map((control) => control.textContent)).toEqual([
|
|
||||||
"评论≥条",
|
|
||||||
"时长≥秒",
|
|
||||||
"点赞≥次",
|
|
||||||
"转发≥次",
|
|
||||||
"完播率≥%",
|
|
||||||
"互动率≥%",
|
|
||||||
"播放中位数≥次"
|
|
||||||
]);
|
|
||||||
expect(buttons.map((button) => button.style.backgroundColor)).toEqual([
|
|
||||||
"rgb(127, 29, 45)",
|
|
||||||
"rgb(127, 29, 45)",
|
|
||||||
"rgb(127, 29, 45)",
|
|
||||||
"rgb(127, 29, 45)"
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("remounts the plugin action bar when the native market action row appears later", async () => {
|
test("remounts the plugin action bar when the native market action row appears later", async () => {
|
||||||
document.body.innerHTML = buildMarketTableOnlyFixture();
|
document.body.innerHTML = buildMarketTableOnlyFixture();
|
||||||
const observer = createMutationObserverFactory();
|
const observer = createMutationObserverFactory();
|
||||||
@ -1289,48 +1169,6 @@ describe("market-content-entry", () => {
|
|||||||
expect(customPagesInput?.hidden).toBe(true);
|
expect(customPagesInput?.hidden).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("toolbar exposes spread threshold filters and disables fixed personal-video controls", async () => {
|
|
||||||
document.body.innerHTML = buildMarketFixture();
|
|
||||||
|
|
||||||
const { createMarketController } = await import("../src/content/market/index");
|
|
||||||
const controller = trackController(createMarketController({
|
|
||||||
document,
|
|
||||||
loadAuthorMetrics: async () => ({
|
|
||||||
success: false,
|
|
||||||
reason: "request-failed"
|
|
||||||
}),
|
|
||||||
window
|
|
||||||
}));
|
|
||||||
|
|
||||||
await controller.ready;
|
|
||||||
|
|
||||||
const videoTypeSelect = document.querySelector(
|
|
||||||
'[data-plugin-spread-filter="type"]'
|
|
||||||
) as HTMLSelectElement | null;
|
|
||||||
const assignSelect = document.querySelector(
|
|
||||||
'[data-plugin-spread-filter="onlyAssign"]'
|
|
||||||
) as HTMLSelectElement | null;
|
|
||||||
const flowTypeSelect = document.querySelector(
|
|
||||||
'[data-plugin-spread-filter="flowType"]'
|
|
||||||
) as HTMLSelectElement | null;
|
|
||||||
const finishRateInput = document.querySelector(
|
|
||||||
'[data-plugin-spread-threshold="finishRate"]'
|
|
||||||
) as HTMLInputElement | null;
|
|
||||||
|
|
||||||
expect(videoTypeSelect?.value).toBe("1");
|
|
||||||
expect(assignSelect?.value).toBe("false");
|
|
||||||
expect(assignSelect?.disabled).toBe(true);
|
|
||||||
expect(flowTypeSelect?.value).toBe("0");
|
|
||||||
expect(flowTypeSelect?.disabled).toBe(true);
|
|
||||||
expect(finishRateInput?.placeholder).toBe("");
|
|
||||||
|
|
||||||
setSelectValue('[data-plugin-spread-filter="type"]', "2");
|
|
||||||
dispatchChange('[data-plugin-spread-filter="type"]');
|
|
||||||
|
|
||||||
expect(assignSelect?.disabled).toBe(false);
|
|
||||||
expect(flowTypeSelect?.disabled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("export uses the current page ordering without triggering a full scan", async () => {
|
test("export uses the current page ordering without triggering a full scan", async () => {
|
||||||
document.body.innerHTML = buildMarketFixture();
|
document.body.innerHTML = buildMarketFixture();
|
||||||
const resultStore = createMarketResultStore();
|
const resultStore = createMarketResultStore();
|
||||||
@ -1379,129 +1217,6 @@ describe("market-content-entry", () => {
|
|||||||
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
|
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("export hydrates spread info with attribute_datas.id before building csv", async () => {
|
|
||||||
document.body.innerHTML = buildRealMarketFixture([
|
|
||||||
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
|
|
||||||
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
|
|
||||||
]);
|
|
||||||
attachMarketListState([
|
|
||||||
{
|
|
||||||
attribute_datas: {
|
|
||||||
id: "spread-a",
|
|
||||||
nickname: "Alpha"
|
|
||||||
},
|
|
||||||
star_id: "a"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute_datas: {
|
|
||||||
id: "spread-b",
|
|
||||||
nickname: "Beta"
|
|
||||||
},
|
|
||||||
star_id: "b"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
const buildCsv = vi.fn(() => "csv-output");
|
|
||||||
const loadSpreadMetrics = vi.fn(async (spreadAuthorId: string) => ({
|
|
||||||
"内容数据-个人视频-近30天-完播率": spreadAuthorId === "spread-a" ? "28.24%" : "18.24%"
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { createMarketController } = await import("../src/content/market/index");
|
|
||||||
const controller = trackController(createMarketController({
|
|
||||||
buildCsv,
|
|
||||||
document,
|
|
||||||
loadAuthorMetrics: async () => ({
|
|
||||||
success: false,
|
|
||||||
reason: "request-failed"
|
|
||||||
}),
|
|
||||||
loadSpreadMetrics,
|
|
||||||
onCsvReady: vi.fn(),
|
|
||||||
window
|
|
||||||
}));
|
|
||||||
|
|
||||||
await controller.ready;
|
|
||||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
|
||||||
dispatchChange('[data-plugin-export-range="select"]');
|
|
||||||
click('[data-plugin-export="button"]');
|
|
||||||
await waitForMockCall(buildCsv, 80, 50);
|
|
||||||
|
|
||||||
expect(loadSpreadMetrics).toHaveBeenCalledWith("spread-a");
|
|
||||||
expect(loadSpreadMetrics).toHaveBeenCalledWith("spread-b");
|
|
||||||
expect(buildCsv.mock.calls[0][0]).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
authorId: "a",
|
|
||||||
spreadAuthorId: "spread-a",
|
|
||||||
spreadMetrics: {
|
|
||||||
"内容数据-个人视频-近30天-完播率": "28.24%"
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
authorId: "b",
|
|
||||||
spreadAuthorId: "spread-b",
|
|
||||||
spreadMetrics: {
|
|
||||||
"内容数据-个人视频-近30天-完播率": "18.24%"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("export keeps only records that match spread threshold filters", async () => {
|
|
||||||
document.body.innerHTML = buildRealMarketFixture([
|
|
||||||
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
|
|
||||||
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
|
|
||||||
]);
|
|
||||||
attachMarketListState([
|
|
||||||
{
|
|
||||||
attribute_datas: {
|
|
||||||
id: "spread-a",
|
|
||||||
nickname: "Alpha"
|
|
||||||
},
|
|
||||||
star_id: "a"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute_datas: {
|
|
||||||
id: "spread-b",
|
|
||||||
nickname: "Beta"
|
|
||||||
},
|
|
||||||
star_id: "b"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
const buildCsv = vi.fn(() => "csv-output");
|
|
||||||
const loadSpreadFilterMetrics = vi.fn(async (spreadAuthorId: string) => ({
|
|
||||||
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%",
|
|
||||||
interactionRate: "5%"
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { createMarketController } = await import("../src/content/market/index");
|
|
||||||
const controller = trackController(createMarketController({
|
|
||||||
buildCsv,
|
|
||||||
document,
|
|
||||||
loadAuthorMetrics: async () => ({
|
|
||||||
success: false,
|
|
||||||
reason: "request-failed"
|
|
||||||
}),
|
|
||||||
loadSpreadFilterMetrics,
|
|
||||||
onCsvReady: vi.fn(),
|
|
||||||
window
|
|
||||||
}));
|
|
||||||
|
|
||||||
await controller.ready;
|
|
||||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
|
||||||
dispatchChange('[data-plugin-export-range="select"]');
|
|
||||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
|
||||||
click('[data-plugin-export="button"]');
|
|
||||||
await waitForMockCall(buildCsv, 80, 50);
|
|
||||||
|
|
||||||
expect(loadSpreadFilterMetrics).toHaveBeenCalledWith("spread-a", {
|
|
||||||
flowType: 0,
|
|
||||||
onlyAssign: false,
|
|
||||||
range: 2,
|
|
||||||
type: 1
|
|
||||||
});
|
|
||||||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
|
|
||||||
"a"
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
test(
|
||||||
"default export captures the first 5 pages and keeps non-empty fields when merging duplicates",
|
"default export captures the first 5 pages and keeps non-empty fields when merging duplicates",
|
||||||
async () => {
|
async () => {
|
||||||
@ -1946,7 +1661,8 @@ describe("market-content-entry", () => {
|
|||||||
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
|
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
|
||||||
const loadBusinessAbility = vi.fn(async () => ({
|
const loadBusinessAbility = vi.fn(async () => ({
|
||||||
estimates: {},
|
estimates: {},
|
||||||
status: "success" as const
|
status: "success" as const,
|
||||||
|
videos: {}
|
||||||
}));
|
}));
|
||||||
const loadAudienceProfile = vi.fn(async (_record, target) => {
|
const loadAudienceProfile = vi.fn(async (_record, target) => {
|
||||||
if (target.source === "fansDistribution" && target.authorType === 5) {
|
if (target.source === "fansDistribution" && target.authorType === 5) {
|
||||||
@ -2034,7 +1750,8 @@ describe("market-content-entry", () => {
|
|||||||
}));
|
}));
|
||||||
const loadBusinessAbility = vi.fn(async () => ({
|
const loadBusinessAbility = vi.fn(async () => ({
|
||||||
estimates: {},
|
estimates: {},
|
||||||
status: "success" as const
|
status: "success" as const,
|
||||||
|
videos: {}
|
||||||
}));
|
}));
|
||||||
const loadAudienceProfile = vi.fn(async () => ({
|
const loadAudienceProfile = vi.fn(async () => ({
|
||||||
age: [{ label: "31-40", value: "60%" }],
|
age: [{ label: "31-40", value: "60%" }],
|
||||||
@ -2166,12 +1883,13 @@ describe("market-content-entry", () => {
|
|||||||
]);
|
]);
|
||||||
window.localStorage.setItem(
|
window.localStorage.setItem(
|
||||||
"sces:audience-profile:selectedHeaders",
|
"sces:audience-profile:selectedHeaders",
|
||||||
JSON.stringify(["内容数据-个人视频-近30天-播放量中位数", "秒思api-看后搜数"])
|
JSON.stringify(["内容数据-个人视频-播放量中位数", "秒思api-看后搜数"])
|
||||||
);
|
);
|
||||||
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
|
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
|
||||||
const loadBusinessAbility = vi.fn(async () => ({
|
const loadBusinessAbility = vi.fn(async () => ({
|
||||||
estimates: {},
|
estimates: {},
|
||||||
status: "success" as const
|
status: "success" as const,
|
||||||
|
videos: {}
|
||||||
}));
|
}));
|
||||||
const loadAudienceProfile = vi.fn(async () => ({
|
const loadAudienceProfile = vi.fn(async () => ({
|
||||||
age: [],
|
age: [],
|
||||||
@ -2205,7 +1923,7 @@ describe("market-content-entry", () => {
|
|||||||
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
|
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
|
||||||
|
|
||||||
expect(buildAudienceProfileCsv.mock.calls[0][1]).toEqual({
|
expect(buildAudienceProfileCsv.mock.calls[0][1]).toEqual({
|
||||||
selectedHeaders: ["内容数据-个人视频-近30天-播放量中位数", "秒思api-看后搜数"]
|
selectedHeaders: ["内容数据-个人视频-播放量中位数", "秒思api-看后搜数"]
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -2241,7 +1959,7 @@ describe("market-content-entry", () => {
|
|||||||
window.localStorage.getItem("sces:audience-profile:selectedHeaders") ?? "[]"
|
window.localStorage.getItem("sces:audience-profile:selectedHeaders") ?? "[]"
|
||||||
) as string[];
|
) as string[];
|
||||||
expect(savedHeaders).not.toContain("秒思api-看后搜数");
|
expect(savedHeaders).not.toContain("秒思api-看后搜数");
|
||||||
expect(savedHeaders).toContain("内容数据-个人视频-近30天-播放量中位数");
|
expect(savedHeaders).toContain("内容数据-个人视频-播放量中位数");
|
||||||
expect(
|
expect(
|
||||||
document.querySelector('[data-plugin-export-status="text"]')?.textContent
|
document.querySelector('[data-plugin-export-status="text"]')?.textContent
|
||||||
).toContain("字段已保存");
|
).toContain("字段已保存");
|
||||||
@ -2440,64 +2158,6 @@ describe("market-content-entry", () => {
|
|||||||
expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId");
|
expect(submitBatch.mock.calls[0]?.[0]).not.toHaveProperty("batchId");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("batch submit keeps only records that match spread threshold filters", async () => {
|
|
||||||
document.body.innerHTML = buildRealMarketFixture([
|
|
||||||
{ authorId: "a", authorName: "Alpha", price21To60s: "450000" },
|
|
||||||
{ authorId: "b", authorName: "Beta", price21To60s: "70000" }
|
|
||||||
]);
|
|
||||||
attachMarketListState([
|
|
||||||
{
|
|
||||||
attribute_datas: {
|
|
||||||
id: "spread-a",
|
|
||||||
nickname: "Alpha"
|
|
||||||
},
|
|
||||||
star_id: "a"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
attribute_datas: {
|
|
||||||
id: "spread-b",
|
|
||||||
nickname: "Beta"
|
|
||||||
},
|
|
||||||
star_id: "b"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
const submitBatch = vi.fn(async () => ({ ok: true }));
|
|
||||||
const loadSpreadFilterMetrics = vi.fn(async (spreadAuthorId: string) => ({
|
|
||||||
finishRate: spreadAuthorId === "spread-a" ? "35%" : "20%"
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { createMarketController } = await import("../src/content/market/index");
|
|
||||||
const controller = trackController(createMarketController({
|
|
||||||
document,
|
|
||||||
getAuthState: async () => ({
|
|
||||||
isAuthenticated: true,
|
|
||||||
resource: "https://talent-search.intelligrow.cn",
|
|
||||||
userInfo: { name: "王少卿", sub: "p7pdhhtde8kj" }
|
|
||||||
}),
|
|
||||||
loadAuthorMetrics: async () => ({
|
|
||||||
success: false,
|
|
||||||
reason: "request-failed"
|
|
||||||
}),
|
|
||||||
loadSpreadFilterMetrics,
|
|
||||||
promptBatchName: () => "筛选批次",
|
|
||||||
submitBatch,
|
|
||||||
window
|
|
||||||
}));
|
|
||||||
|
|
||||||
await controller.ready;
|
|
||||||
setSelectValue('[data-plugin-export-range="select"]', "current");
|
|
||||||
dispatchChange('[data-plugin-export-range="select"]');
|
|
||||||
setInputValue('[data-plugin-spread-threshold="finishRate"]', "30");
|
|
||||||
click('[data-plugin-batch-submit="button"]');
|
|
||||||
await waitForMockCall(submitBatch, 80, 50);
|
|
||||||
|
|
||||||
expect(submitBatch.mock.calls[0]?.[0].authors).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
authorId: "a"
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opens a custom batch name dialog before submitting", async () => {
|
test("opens a custom batch name dialog before submitting", async () => {
|
||||||
document.body.innerHTML = buildMarketFixture();
|
document.body.innerHTML = buildMarketFixture();
|
||||||
const submitBatch = vi.fn(async () => ({ ok: true }));
|
const submitBatch = vi.fn(async () => ({ ok: true }));
|
||||||
@ -3893,58 +3553,6 @@ describe("market-content-entry", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function clearLocalStorage(): void {
|
|
||||||
if (typeof window.localStorage.clear === "function") {
|
|
||||||
window.localStorage.clear();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let index = window.localStorage.length - 1; index >= 0; index -= 1) {
|
|
||||||
const key = window.localStorage.key(index);
|
|
||||||
if (key) {
|
|
||||||
window.localStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function installUsableLocalStorage(): void {
|
|
||||||
if (
|
|
||||||
typeof window.localStorage.getItem === "function" &&
|
|
||||||
typeof window.localStorage.setItem === "function" &&
|
|
||||||
typeof window.localStorage.removeItem === "function" &&
|
|
||||||
typeof window.localStorage.clear === "function"
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = new Map<string, string>();
|
|
||||||
const storage: Storage = {
|
|
||||||
get length() {
|
|
||||||
return values.size;
|
|
||||||
},
|
|
||||||
clear() {
|
|
||||||
values.clear();
|
|
||||||
},
|
|
||||||
getItem(key: string) {
|
|
||||||
return values.get(key) ?? null;
|
|
||||||
},
|
|
||||||
key(index: number) {
|
|
||||||
return Array.from(values.keys())[index] ?? null;
|
|
||||||
},
|
|
||||||
removeItem(key: string) {
|
|
||||||
values.delete(key);
|
|
||||||
},
|
|
||||||
setItem(key: string, value: string) {
|
|
||||||
values.set(key, String(value));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Object.defineProperty(window, "localStorage", {
|
|
||||||
configurable: true,
|
|
||||||
value: storage
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMarketFixture() {
|
function buildMarketFixture() {
|
||||||
return buildMarketPageShell(buildMarketTableOnlyFixture());
|
return buildMarketPageShell(buildMarketTableOnlyFixture());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -155,49 +155,6 @@ describe("silent-export-controller", () => {
|
|||||||
expect(records?.map((record) => record.authorId)).toEqual(["2", "3"]);
|
expect(records?.map((record) => record.authorId)).toEqual(["2", "3"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps attribute_datas.id as the spread author id while preserving star_id as row id", async () => {
|
|
||||||
document.documentElement.setAttribute(
|
|
||||||
"data-sces-market-request-snapshot",
|
|
||||||
JSON.stringify({
|
|
||||||
body: JSON.stringify({
|
|
||||||
page_param: {
|
|
||||||
page: 1
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
method: "POST",
|
|
||||||
url: "https://xingtu.cn/api/mock-market-search"
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const controller = createSilentExportController({
|
|
||||||
document,
|
|
||||||
fetchImpl: async () => ({
|
|
||||||
json: async () => ({
|
|
||||||
authors: [
|
|
||||||
{
|
|
||||||
attribute_datas: {
|
|
||||||
id: "spread-1",
|
|
||||||
nickname: "达人1"
|
|
||||||
},
|
|
||||||
star_id: "row-1"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}),
|
|
||||||
ok: true
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const records = await controller.exportRecords({
|
|
||||||
mode: "count",
|
|
||||||
pageCount: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(records?.[0]).toMatchObject({
|
|
||||||
authorId: "row-1",
|
|
||||||
spreadAuthorId: "spread-1"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("starts from page 1 when the captured request omits an explicit page number", async () => {
|
test("starts from page 1 when the captured request omits an explicit page number", async () => {
|
||||||
document.documentElement.setAttribute(
|
document.documentElement.setAttribute(
|
||||||
"data-sces-market-request-snapshot",
|
"data-sces-market-request-snapshot",
|
||||||
|
|||||||
@ -1,214 +0,0 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildSpreadInfoColumns,
|
|
||||||
buildSpreadInfoUrl,
|
|
||||||
createSpreadInfoClient,
|
|
||||||
DEFAULT_SPREAD_INFO_CONFIGS,
|
|
||||||
matchesSpreadThresholds,
|
|
||||||
mapSpreadInfoResponse
|
|
||||||
} from "../src/content/market/spread-info";
|
|
||||||
|
|
||||||
describe("spread-info", () => {
|
|
||||||
test("builds the spread info url with all request parameters", () => {
|
|
||||||
expect(
|
|
||||||
buildSpreadInfoUrl(
|
|
||||||
"7361012802036695050",
|
|
||||||
{
|
|
||||||
flowType: 1,
|
|
||||||
onlyAssign: true,
|
|
||||||
range: 2,
|
|
||||||
type: 2
|
|
||||||
},
|
|
||||||
"https://www.xingtu.cn"
|
|
||||||
)
|
|
||||||
).toBe(
|
|
||||||
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=7361012802036695050&platform_source=1&platform_channel=1&type=2&flow_type=1&only_assign=true&range=2"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("defines personal video ranges with fixed non-prefix parameters", () => {
|
|
||||||
const personalConfigs = DEFAULT_SPREAD_INFO_CONFIGS.filter(
|
|
||||||
(config) => config.type === 1
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(personalConfigs).toEqual([
|
|
||||||
{
|
|
||||||
flowType: 0,
|
|
||||||
onlyAssign: false,
|
|
||||||
range: 2,
|
|
||||||
type: 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
flowType: 0,
|
|
||||||
onlyAssign: false,
|
|
||||||
range: 3,
|
|
||||||
type: 1
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
expect(buildSpreadInfoColumns(personalConfigs).slice(0, 2)).toEqual([
|
|
||||||
"内容数据-个人视频-近30天-完播率",
|
|
||||||
"内容数据-个人视频-近30天-播放量中位数"
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("defines all xingtu video assign flow and range combinations", () => {
|
|
||||||
const xingtuConfigs = DEFAULT_SPREAD_INFO_CONFIGS.filter(
|
|
||||||
(config) => config.type === 2
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(xingtuConfigs).toHaveLength(8);
|
|
||||||
expect(xingtuConfigs).toContainEqual({
|
|
||||||
flowType: 1,
|
|
||||||
onlyAssign: true,
|
|
||||||
range: 2,
|
|
||||||
type: 2
|
|
||||||
});
|
|
||||||
expect(xingtuConfigs).toContainEqual({
|
|
||||||
flowType: 0,
|
|
||||||
onlyAssign: false,
|
|
||||||
range: 3,
|
|
||||||
type: 2
|
|
||||||
});
|
|
||||||
expect(buildSpreadInfoColumns([
|
|
||||||
{
|
|
||||||
flowType: 1,
|
|
||||||
onlyAssign: true,
|
|
||||||
range: 2,
|
|
||||||
type: 2
|
|
||||||
}
|
|
||||||
])).toEqual([
|
|
||||||
"内容数据-只看指派-排除营销流量-星图视频-近30天-完播率",
|
|
||||||
"内容数据-只看指派-排除营销流量-星图视频-近30天-播放量中位数",
|
|
||||||
"内容数据-只看指派-排除营销流量-星图视频-近30天-互动率",
|
|
||||||
"内容数据-只看指派-排除营销流量-星图视频-近30天-作品平均时长",
|
|
||||||
"内容数据-只看指派-排除营销流量-星图视频-近30天-作品平均评论数",
|
|
||||||
"内容数据-只看指派-排除营销流量-星图视频-近30天-作品平均点赞数",
|
|
||||||
"内容数据-只看指派-排除营销流量-星图视频-近30天-作品平均转发数"
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maps spread info response values into display values", () => {
|
|
||||||
expect(
|
|
||||||
mapSpreadInfoResponse({
|
|
||||||
avg_duration: "5600",
|
|
||||||
comment_avg: "7502",
|
|
||||||
interact_rate: {
|
|
||||||
value: 402
|
|
||||||
},
|
|
||||||
item_rate: {
|
|
||||||
play_mid: {
|
|
||||||
value: 10913233
|
|
||||||
}
|
|
||||||
},
|
|
||||||
like_avg: "494458",
|
|
||||||
play_over_rate: {
|
|
||||||
value: 2824
|
|
||||||
},
|
|
||||||
share_avg: "188267"
|
|
||||||
})
|
|
||||||
).toEqual({
|
|
||||||
averageCommentCount: "7502",
|
|
||||||
averageDuration: "56",
|
|
||||||
averageLikeCount: "494458",
|
|
||||||
averageShareCount: "188267",
|
|
||||||
finishRate: "28.24%",
|
|
||||||
interactionRate: "4.02%",
|
|
||||||
playMedian: "10913233"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("loads each configured spread metric column for one author", async () => {
|
|
||||||
const fetchImpl = vi.fn(async () => ({
|
|
||||||
json: async () => ({
|
|
||||||
avg_duration: "5600",
|
|
||||||
comment_avg: "7502",
|
|
||||||
interact_rate: {
|
|
||||||
value: 402
|
|
||||||
},
|
|
||||||
like_avg: "494458",
|
|
||||||
play_mid: "10913233",
|
|
||||||
play_over_rate: {
|
|
||||||
value: 2824
|
|
||||||
},
|
|
||||||
share_avg: "188267"
|
|
||||||
}),
|
|
||||||
ok: true
|
|
||||||
}));
|
|
||||||
const client = createSpreadInfoClient({
|
|
||||||
configs: [
|
|
||||||
{
|
|
||||||
flowType: 0,
|
|
||||||
onlyAssign: false,
|
|
||||||
range: 2,
|
|
||||||
type: 1
|
|
||||||
}
|
|
||||||
],
|
|
||||||
fetchImpl
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
client.loadAuthorSpreadMetrics("7361012802036695050")
|
|
||||||
).resolves.toEqual({
|
|
||||||
"内容数据-个人视频-近30天-互动率": "4.02%",
|
|
||||||
"内容数据-个人视频-近30天-作品平均点赞数": "494458",
|
|
||||||
"内容数据-个人视频-近30天-作品平均评论数": "7502",
|
|
||||||
"内容数据-个人视频-近30天-作品平均时长": "56",
|
|
||||||
"内容数据-个人视频-近30天-作品平均转发数": "188267",
|
|
||||||
"内容数据-个人视频-近30天-完播率": "28.24%",
|
|
||||||
"内容数据-个人视频-近30天-播放量中位数": "10913233"
|
|
||||||
});
|
|
||||||
expect(fetchImpl).toHaveBeenCalledWith(
|
|
||||||
"https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=7361012802036695050&platform_source=1&platform_channel=1&type=1&flow_type=0&only_assign=false&range=2",
|
|
||||||
expect.objectContaining({
|
|
||||||
credentials: "include",
|
|
||||||
method: "GET"
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("matches thresholds using display values and requires every filled threshold", () => {
|
|
||||||
expect(
|
|
||||||
matchesSpreadThresholds(
|
|
||||||
{
|
|
||||||
averageDuration: "56",
|
|
||||||
finishRate: "28.24%",
|
|
||||||
interactionRate: "4.02%",
|
|
||||||
playMedian: "10913233"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
averageDuration: 50,
|
|
||||||
finishRate: 28,
|
|
||||||
interactionRate: 4,
|
|
||||||
playMedian: 10000000
|
|
||||||
}
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
matchesSpreadThresholds(
|
|
||||||
{
|
|
||||||
averageDuration: "56",
|
|
||||||
finishRate: "28.24%",
|
|
||||||
interactionRate: "4.02%",
|
|
||||||
playMedian: "10913233"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
averageDuration: 57
|
|
||||||
}
|
|
||||||
)
|
|
||||||
).toBe(false);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
matchesSpreadThresholds(
|
|
||||||
{
|
|
||||||
finishRate: "28.24%"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
finishRate: 20,
|
|
||||||
interactionRate: 1
|
|
||||||
}
|
|
||||||
)
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
x
Reference in New Issue
Block a user