Compare commits
4 Commits
49017650ba
...
f2ac751c4c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2ac751c4c | ||
|
|
cac6c5e83b | ||
|
|
74671a9b9f | ||
|
|
f37d05dd88 |
1
.gitignore
vendored
@@ -39,6 +39,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
*.tsbuildinfo
|
||||||
/dist/
|
/dist/
|
||||||
/.wrangler/
|
/.wrangler/
|
||||||
/outputs/
|
/outputs/
|
||||||
|
|||||||
18
README.md
@@ -1,6 +1,6 @@
|
|||||||
# KOC LOOP
|
# KOC LOOP
|
||||||
|
|
||||||
KOC 内容分发与数据回收闭环。当前私有化分支运行于标准 Next.js Node.js、MySQL 8、Nginx 和本地持久化文件存储。
|
KOC 内容分发与数据回收闭环。`main` 分支运行于标准 Next.js Node.js、MySQL 8、Nginx 和本地持久化文件存储。
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -45,13 +45,21 @@ npm run build
|
|||||||
|
|
||||||
## KOC 资源导入
|
## KOC 资源导入
|
||||||
|
|
||||||
超级管理员和管理员可在“KOC资源”页面下载标准 Excel 模板,批量导入已有资源。模板只需填写小红书账号主页,合作来源可选填;上传后系统自动解析账号名称、小红书号、IP属地和粉丝数。
|
超级管理员和管理员可在“KOC资源”页面下载标准 Excel 模板,批量导入已有资源。模板只需填写账号主页;账号名称、账号 ID、IP 属地、粉丝数、性别、简介、标签和合作来源均可选填。多个标签使用逗号分隔,每个账号最多 5 个标签。
|
||||||
|
|
||||||
- 单次最多导入 100 个账号,支持 `.xlsx` 和 `.csv`,文件不超过 5MB。
|
- 单次最多导入 10,000 个账号,支持 `.xlsx` 和 `.csv`,文件不超过 20MB。
|
||||||
- 当前自动解析支持小红书账号主页;上传后先展示新增、更新和异常数据,存在异常时不会写入数据库。
|
- 当前自动解析支持小红书账号主页;上传后先展示新增、更新和异常数据。异常行会跳过,其余有效账号可以正常导入。
|
||||||
- 按“平台 + 账号主页”去重;解析出相同小红书号时也会更新已有账号。
|
- 按“平台 + 账号主页”去重;解析出相同小红书号时也会更新已有账号。
|
||||||
- 重复账号更新公开资料和合作来源,不产生两份资源。
|
- 重复账号更新公开资料和合作来源,不产生两份资源。
|
||||||
- 导入的合作来源会进入现有资源搜索、筛选和导出结果。
|
- 大批量导入会先写入资源库,再在后台逐步补全缺失的公开资料。
|
||||||
|
- KOC 使用手机号或微信号领取任务后,系统会把该值写入“当前联系人”;原“合作来源”继续保留渠道信息。
|
||||||
|
- 导入的标签、当前联系人和合作来源会进入资源搜索或导出结果。
|
||||||
|
|
||||||
|
## KOC 批量回填 Excel
|
||||||
|
|
||||||
|
KOC 领取端支持导出和上传批量回填表。视频任务只生成“序号、标题、笔记内容、视频、发布链接、笔记截图、数据分析截图”列,不生成“图片”列。视频链接通过当前公网域名生成,下载接口返回可播放的 `.mp4` 附件。
|
||||||
|
|
||||||
|
反向代理部署必须正确传递 `Host`、`X-Forwarded-Host` 和 `X-Forwarded-Proto`,并把 `APP_ORIGIN` 配置为实际公网地址;不要填写 `localhost` 或容器内部地址。
|
||||||
|
|
||||||
## Agent MCP
|
## Agent MCP
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +39,8 @@ type Task = {
|
|||||||
due_at: string;
|
due_at: string;
|
||||||
status: string;
|
status: string;
|
||||||
task_type: "content_publish" | "screenshot_collect";
|
task_type: "content_publish" | "screenshot_collect";
|
||||||
|
platform: "小红书" | "抖音";
|
||||||
|
content_format: "image_text" | "video";
|
||||||
source_url: string;
|
source_url: string;
|
||||||
source_sheet_id: string;
|
source_sheet_id: string;
|
||||||
source_sheet_name: string;
|
source_sheet_name: string;
|
||||||
@@ -55,12 +58,16 @@ type Account = {
|
|||||||
public_account_id: string;
|
public_account_id: string;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
profile_url: string;
|
profile_url: string;
|
||||||
|
latest_publish_url: string;
|
||||||
ip_location: string;
|
ip_location: string;
|
||||||
followers: number;
|
followers: number;
|
||||||
|
gender: string;
|
||||||
|
bio: string;
|
||||||
|
tags: string;
|
||||||
post_count: number;
|
post_count: number;
|
||||||
avg_views: number;
|
avg_views: number;
|
||||||
cooperation_source: string;
|
cooperation_source: string;
|
||||||
tags: string;
|
current_contact: string;
|
||||||
first_seen_at: string;
|
first_seen_at: string;
|
||||||
last_seen_at: string;
|
last_seen_at: string;
|
||||||
};
|
};
|
||||||
@@ -74,12 +81,16 @@ type ResourceImportPreview = {
|
|||||||
publicAccountId: string;
|
publicAccountId: string;
|
||||||
ipLocation: string;
|
ipLocation: string;
|
||||||
followers: number;
|
followers: number;
|
||||||
|
gender: string;
|
||||||
|
bio: string;
|
||||||
|
tags: string[];
|
||||||
cooperationSource: string;
|
cooperationSource: string;
|
||||||
tags: string;
|
|
||||||
action: "create" | "update" | "error";
|
action: "create" | "update" | "error";
|
||||||
errors: string[];
|
errors: string[];
|
||||||
}>;
|
}>;
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
|
deferredEnrichment?: number;
|
||||||
|
maxRows?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Distribution = {
|
type Distribution = {
|
||||||
@@ -111,6 +122,7 @@ type Distribution = {
|
|||||||
latest_likes: number | null;
|
latest_likes: number | null;
|
||||||
latest_comments: number | null;
|
latest_comments: number | null;
|
||||||
latest_collects: number | null;
|
latest_collects: number | null;
|
||||||
|
latest_shares: number | null;
|
||||||
collection_status: string;
|
collection_status: string;
|
||||||
collection_status_description: string | null;
|
collection_status_description: string | null;
|
||||||
collection_updated_at: string | null;
|
collection_updated_at: string | null;
|
||||||
@@ -118,11 +130,14 @@ type Distribution = {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
content_title: string;
|
content_title: string;
|
||||||
partner_name: string;
|
partner_name: string;
|
||||||
|
claimant_name: string | null;
|
||||||
account_nickname: string | null;
|
account_nickname: string | null;
|
||||||
account_platform: string | null;
|
account_platform: string | null;
|
||||||
task_name: string;
|
task_name: string;
|
||||||
task_brand: string;
|
task_brand: string;
|
||||||
task_type: "content_publish" | "screenshot_collect";
|
task_type: "content_publish" | "screenshot_collect";
|
||||||
|
task_platform: "小红书" | "抖音";
|
||||||
|
content_format: "image_text" | "video";
|
||||||
due_at: string;
|
due_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,6 +145,7 @@ type RecoverySortKey =
|
|||||||
| "publish_time"
|
| "publish_time"
|
||||||
| "likes"
|
| "likes"
|
||||||
| "collects"
|
| "collects"
|
||||||
|
| "shares"
|
||||||
| "comments"
|
| "comments"
|
||||||
| "total"
|
| "total"
|
||||||
| "collection_updated_at";
|
| "collection_updated_at";
|
||||||
@@ -147,6 +163,8 @@ type FeishuPreview = {
|
|||||||
sheetName: string;
|
sheetName: string;
|
||||||
syncedAt: string;
|
syncedAt: string;
|
||||||
rowCount: number;
|
rowCount: number;
|
||||||
|
imageCount: number;
|
||||||
|
videoCount: number;
|
||||||
columns: string[];
|
columns: string[];
|
||||||
preview: Array<{
|
preview: Array<{
|
||||||
sourceRow: number;
|
sourceRow: number;
|
||||||
@@ -202,6 +220,67 @@ function formatNumber(value: number | null | undefined) {
|
|||||||
return new Intl.NumberFormat("zh-CN").format(value);
|
return new Intl.NumberFormat("zh-CN").format(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resourceProfileLink(account: Account) {
|
||||||
|
const storedProfileUrl = account.profile_url?.trim();
|
||||||
|
if (account.platform !== "抖音") {
|
||||||
|
return storedProfileUrl
|
||||||
|
? { href: storedProfileUrl, label: "查看主页 ↗" }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicAccountId = account.public_account_id?.trim();
|
||||||
|
const hasResolvedPublicId = Boolean(
|
||||||
|
publicAccountId && publicAccountId !== "待识别",
|
||||||
|
);
|
||||||
|
if (storedProfileUrl && hasResolvedPublicId) {
|
||||||
|
return { href: storedProfileUrl, label: "查看主页 ↗" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestPublishUrl = account.latest_publish_url?.trim();
|
||||||
|
if (latestPublishUrl) {
|
||||||
|
return {
|
||||||
|
href: latestPublishUrl,
|
||||||
|
label: "通过作品查看主页 ↗",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyword = hasResolvedPublicId ? publicAccountId : account.nickname.trim();
|
||||||
|
return keyword
|
||||||
|
? {
|
||||||
|
href: `https://www.douyin.com/search/${encodeURIComponent(keyword)}?type=user`,
|
||||||
|
label: "搜索主页 ↗",
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlatformBadge({
|
||||||
|
platform,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
platform: string | null | undefined;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const normalized = platform === "抖音" ? "抖音" : platform === "小红书" ? "小红书" : "其他";
|
||||||
|
return (
|
||||||
|
<span className={`platform-badge ${compact ? "compact" : ""}`}>
|
||||||
|
<span className={`platform-logo ${normalized === "抖音" ? "douyin" : normalized === "小红书" ? "xiaohongshu" : "other"}`} aria-hidden="true">
|
||||||
|
{normalized === "抖音" ? (
|
||||||
|
<svg viewBox="0 0 24 24" focusable="false">
|
||||||
|
<path className="douyin-cyan" d="M14.2 3.2v9.5a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V3.2h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1V10c-1.7-.1-3.2-.7-4.3-1.7V3.2Z" />
|
||||||
|
<path className="douyin-red" d="M15.2 2.5V12a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V2.5h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1v2.7c-1.7-.1-3.2-.7-4.3-1.7V2.5Z" />
|
||||||
|
<path className="douyin-white" d="M14.7 2.9v9.5a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V2.9h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1v2.7c-1.7-.1-3.2-.7-4.3-1.7V2.9Z" />
|
||||||
|
</svg>
|
||||||
|
) : normalized === "小红书" ? (
|
||||||
|
<b>小红书</b>
|
||||||
|
) : (
|
||||||
|
<b>媒</b>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span>{platform || "未知平台"}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function statusLabel(status: string) {
|
function statusLabel(status: string) {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
claimed: "待发布",
|
claimed: "待发布",
|
||||||
@@ -232,14 +311,16 @@ function latestPublicMetrics(distribution: Distribution) {
|
|||||||
const collects =
|
const collects =
|
||||||
distribution.latest_collects ??
|
distribution.latest_collects ??
|
||||||
(legacyDay ? distribution[`d${legacyDay}_collects`] : null);
|
(legacyDay ? distribution[`d${legacyDay}_collects`] : null);
|
||||||
|
const shares = distribution.latest_shares ?? 0;
|
||||||
return {
|
return {
|
||||||
likes,
|
likes,
|
||||||
comments,
|
comments,
|
||||||
collects,
|
collects,
|
||||||
|
shares,
|
||||||
total:
|
total:
|
||||||
likes === null
|
likes === null
|
||||||
? null
|
? null
|
||||||
: likes + (comments ?? 0) + (collects ?? 0),
|
: likes + (comments ?? 0) + (collects ?? 0) + shares,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +331,7 @@ function recoverySortValue(
|
|||||||
const metrics = latestPublicMetrics(distribution);
|
const metrics = latestPublicMetrics(distribution);
|
||||||
if (key === "likes") return metrics.likes;
|
if (key === "likes") return metrics.likes;
|
||||||
if (key === "collects") return metrics.collects;
|
if (key === "collects") return metrics.collects;
|
||||||
|
if (key === "shares") return metrics.shares;
|
||||||
if (key === "comments") return metrics.comments;
|
if (key === "comments") return metrics.comments;
|
||||||
if (key === "total") return metrics.total;
|
if (key === "total") return metrics.total;
|
||||||
|
|
||||||
@@ -343,19 +425,21 @@ function addCalendarDays(value: string, days: number) {
|
|||||||
return date.toISOString().slice(0, 10);
|
return date.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
function xhsPublishUrl(value: string | null) {
|
function publicPublishUrl(value: string | null) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
try {
|
try {
|
||||||
const url = new URL(value);
|
const url = new URL(value);
|
||||||
const hostname = url.hostname.toLowerCase();
|
const hostname = url.hostname.toLowerCase();
|
||||||
const isXhsHost =
|
const isSupportedHost =
|
||||||
hostname === "xiaohongshu.com" ||
|
hostname === "xiaohongshu.com" ||
|
||||||
hostname.endsWith(".xiaohongshu.com") ||
|
hostname.endsWith(".xiaohongshu.com") ||
|
||||||
hostname === "xhslink.cn" ||
|
hostname === "xhslink.cn" ||
|
||||||
hostname.endsWith(".xhslink.cn") ||
|
hostname.endsWith(".xhslink.cn") ||
|
||||||
hostname === "xhslink.com" ||
|
hostname === "xhslink.com" ||
|
||||||
hostname.endsWith(".xhslink.com");
|
hostname.endsWith(".xhslink.com") ||
|
||||||
return ["http:", "https:"].includes(url.protocol) && isXhsHost
|
hostname === "douyin.com" ||
|
||||||
|
hostname.endsWith(".douyin.com");
|
||||||
|
return ["http:", "https:"].includes(url.protocol) && isSupportedHost
|
||||||
? url.toString()
|
? url.toString()
|
||||||
: null;
|
: null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -417,6 +501,8 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
const [metricModal, setMetricModal] = useState<Distribution | null>(null);
|
const [metricModal, setMetricModal] = useState<Distribution | null>(null);
|
||||||
const [taskForm, setTaskForm] = useState({
|
const [taskForm, setTaskForm] = useState({
|
||||||
taskType: "content_publish" as "content_publish" | "screenshot_collect",
|
taskType: "content_publish" as "content_publish" | "screenshot_collect",
|
||||||
|
platform: "小红书" as "小红书" | "抖音",
|
||||||
|
contentFormat: "image_text" as "image_text" | "video",
|
||||||
name: "",
|
name: "",
|
||||||
brand: "",
|
brand: "",
|
||||||
dueAt: "2026-08-12",
|
dueAt: "2026-08-12",
|
||||||
@@ -529,6 +615,14 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
setToast("请先读取飞书表格");
|
setToast("请先读取飞书表格");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
taskForm.taskType === "content_publish" &&
|
||||||
|
taskForm.contentFormat === "video" &&
|
||||||
|
(sourcePreview?.videoCount ?? 0) === 0
|
||||||
|
) {
|
||||||
|
setToast("当前飞书表格没有识别到视频附件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let exampleImageKey = "";
|
let exampleImageKey = "";
|
||||||
if (taskForm.taskType === "screenshot_collect" && taskExampleImage) {
|
if (taskForm.taskType === "screenshot_collect" && taskExampleImage) {
|
||||||
try {
|
try {
|
||||||
@@ -576,6 +670,8 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
setTaskExampleImage(null);
|
setTaskExampleImage(null);
|
||||||
setTaskForm({
|
setTaskForm({
|
||||||
taskType: "content_publish",
|
taskType: "content_publish",
|
||||||
|
platform: "小红书",
|
||||||
|
contentFormat: "image_text",
|
||||||
name: "",
|
name: "",
|
||||||
brand: "",
|
brand: "",
|
||||||
dueAt: "2026-08-12",
|
dueAt: "2026-08-12",
|
||||||
@@ -612,7 +708,9 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
...current,
|
...current,
|
||||||
name: current.name || result.sheetName.replace(/500篇$/, "分发任务"),
|
name: current.name || result.sheetName.replace(/500篇$/, "分发任务"),
|
||||||
}));
|
}));
|
||||||
setToast(`已读取 ${result.rowCount} 篇有效内容`);
|
setToast(
|
||||||
|
`已读取 ${result.rowCount} 条有效内容,识别到 ${result.videoCount} 个视频`,
|
||||||
|
);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setSourcePreview(null);
|
setSourcePreview(null);
|
||||||
setToast(reason instanceof Error ? reason.message : "读取飞书表格失败");
|
setToast(reason instanceof Error ? reason.message : "读取飞书表格失败");
|
||||||
@@ -670,6 +768,32 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateDistributionPublishUrl = async (
|
||||||
|
distribution: Distribution,
|
||||||
|
) => {
|
||||||
|
const publishInput = window.prompt(
|
||||||
|
distribution.publish_url
|
||||||
|
? "更新小红书笔记链接(支持长链、短链或完整分享文案)"
|
||||||
|
: "填写小红书笔记链接(支持长链、短链或完整分享文案)",
|
||||||
|
distribution.publish_url || "",
|
||||||
|
);
|
||||||
|
if (publishInput === null) return;
|
||||||
|
if (!publishInput.trim()) {
|
||||||
|
setToast("请填写笔记链接");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runAction(
|
||||||
|
{
|
||||||
|
action: "update_distribution_publish_url",
|
||||||
|
distributionId: distribution.id,
|
||||||
|
publishUrl: publishInput.trim(),
|
||||||
|
},
|
||||||
|
distribution.publish_url
|
||||||
|
? "笔记链接已更新,旧公开采集数据已清空"
|
||||||
|
: "笔记链接已补充,可开始数据采集",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const retryFailedMetrics = async (taskId: string, failedCount: number) => {
|
const retryFailedMetrics = async (taskId: string, failedCount: number) => {
|
||||||
if (failedCount === 0) {
|
if (failedCount === 0) {
|
||||||
setToast("当前没有采集异常的笔记");
|
setToast("当前没有采集异常的笔记");
|
||||||
@@ -999,7 +1123,9 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
tasks={data.tasks}
|
tasks={data.tasks}
|
||||||
distributions={data.distributions}
|
distributions={data.distributions}
|
||||||
working={working}
|
working={working}
|
||||||
|
canUpdatePublishUrl={isManager}
|
||||||
onCollect={collectMetrics}
|
onCollect={collectMetrics}
|
||||||
|
onUpdatePublishUrl={updateDistributionPublishUrl}
|
||||||
onSaveSchedule={saveCollectionSchedule}
|
onSaveSchedule={saveCollectionSchedule}
|
||||||
onUpload={uploadScreenshot}
|
onUpload={uploadScreenshot}
|
||||||
onFallback={openMetricFallback}
|
onFallback={openMetricFallback}
|
||||||
@@ -1047,6 +1173,42 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
</div>
|
</div>
|
||||||
{taskForm.taskType === "content_publish" ? (
|
{taskForm.taskType === "content_publish" ? (
|
||||||
<>
|
<>
|
||||||
|
<div className="form-grid two">
|
||||||
|
<label>
|
||||||
|
<span className="platform-meta-line">
|
||||||
|
<span>发布平台</span>
|
||||||
|
<PlatformBadge platform={taskForm.platform} compact />
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={taskForm.platform}
|
||||||
|
onChange={(event) =>
|
||||||
|
setTaskForm({
|
||||||
|
...taskForm,
|
||||||
|
platform: event.target.value as "小红书" | "抖音",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="小红书">小红书</option>
|
||||||
|
<option value="抖音">抖音</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>内容类型</span>
|
||||||
|
<select
|
||||||
|
value={taskForm.contentFormat}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTaskForm({
|
||||||
|
...taskForm,
|
||||||
|
contentFormat: event.target.value as "image_text" | "video",
|
||||||
|
});
|
||||||
|
setSourcePreview(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="image_text">图文</option>
|
||||||
|
<option value="video">视频</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<label>
|
<label>
|
||||||
<span>飞书内容表链接</span>
|
<span>飞书内容表链接</span>
|
||||||
<div className="source-url-row">
|
<div className="source-url-row">
|
||||||
@@ -1068,7 +1230,11 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
{sourceWorking ? "读取中…" : "读取表格"}
|
{sourceWorking ? "读取中…" : "读取表格"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<small>自动读取“标题、正文/笔记内容、标签和全部配图”,空标题行不会进入任务。</small>
|
<small>
|
||||||
|
{taskForm.contentFormat === "video"
|
||||||
|
? "自动读取标题、正文和视频附件;空标题或无视频的行不会进入任务。"
|
||||||
|
: "自动读取标题、正文/笔记内容、标签和全部配图;空标题行不会进入任务。"}
|
||||||
|
</small>
|
||||||
</label>
|
</label>
|
||||||
{sourcePreview && (
|
{sourcePreview && (
|
||||||
<div className="source-preview">
|
<div className="source-preview">
|
||||||
@@ -1080,7 +1246,11 @@ export default function Home({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
<small>工作表 {sourcePreview.sheetId} · 已匹配 {sourcePreview.columns.length} 个字段</small>
|
<small>工作表 {sourcePreview.sheetId} · 已匹配 {sourcePreview.columns.length} 个字段</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<b>{sourcePreview.rowCount} 篇</b>
|
<b>
|
||||||
|
{sourcePreview.rowCount} 条 · {taskForm.contentFormat === "video"
|
||||||
|
? `${sourcePreview.videoCount} 个视频`
|
||||||
|
: `${sourcePreview.imageCount} 张图片`}
|
||||||
|
</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="source-columns">
|
<div className="source-columns">
|
||||||
{sourcePreview.columns.map((column) => <span key={column}>{column}</span>)}
|
{sourcePreview.columns.map((column) => <span key={column}>{column}</span>)}
|
||||||
@@ -1334,7 +1504,7 @@ function OverviewPage({
|
|||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">今天最需要推进</p>
|
<p className="eyebrow">今天最需要推进</p>
|
||||||
<h3>把已发布笔记的数据收回来</h3>
|
<h3>把已发布笔记的数据收回来</h3>
|
||||||
<p>按任务选择采集日期,系统在当天09:00更新点赞、收藏、评论和总互动。</p>
|
<p>按任务选择采集日期,系统在当天09:00更新点赞、收藏、评论、转发和总互动。</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="primary-button soft" onClick={() => onNavigate("recovery")}>进入数据回收</button>
|
<button className="primary-button soft" onClick={() => onNavigate("recovery")}>进入数据回收</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1351,7 +1521,14 @@ function OverviewPage({
|
|||||||
<article key={task.id}>
|
<article key={task.id}>
|
||||||
<div className="task-mini-top">
|
<div className="task-mini-top">
|
||||||
<div className={`avatar ${avatarColor(task.name)}`}>{task.brand.slice(0, 1)}</div>
|
<div className={`avatar ${avatarColor(task.name)}`}>{task.brand.slice(0, 1)}</div>
|
||||||
<div><strong>{task.name}</strong><span>{task.brand}</span></div>
|
<div>
|
||||||
|
<strong>{task.name}</strong>
|
||||||
|
<span className="platform-meta-line">
|
||||||
|
<span>{task.brand}</span>
|
||||||
|
<PlatformBadge platform={task.platform} compact />
|
||||||
|
<span>{task.content_format === "video" ? "视频" : "图文"}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<b>{progress}%</b>
|
<b>{progress}%</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="progress"><span style={{ width: `${progress}%` }} /></div>
|
<div className="progress"><span style={{ width: `${progress}%` }} /></div>
|
||||||
@@ -1411,12 +1588,16 @@ function TasksPage({
|
|||||||
<div className={`avatar large ${avatarColor(task.name)}`}>{task.brand.slice(0, 1)}</div>
|
<div className={`avatar large ${avatarColor(task.name)}`}>{task.brand.slice(0, 1)}</div>
|
||||||
<div className="task-row-main">
|
<div className="task-row-main">
|
||||||
<div><h3>{task.name}</h3><span>{task.brand}</span></div>
|
<div><h3>{task.name}</h3><span>{task.brand}</span></div>
|
||||||
<small>
|
<small className="platform-meta-line">
|
||||||
{task.task_type === "screenshot_collect"
|
{task.task_type === "screenshot_collect" ? (
|
||||||
? "截图回收 · 关键词任务"
|
<span>截图回收 · 关键词任务</span>
|
||||||
: task.source_sheet_name
|
) : (
|
||||||
? `飞书 · ${task.source_sheet_name}`
|
<>
|
||||||
: "历史示例内容表"}
|
<PlatformBadge platform={task.platform} compact />
|
||||||
|
<span>{task.content_format === "video" ? "视频" : "图文"}</span>
|
||||||
|
<span>{task.source_sheet_name ? `飞书 ${task.source_sheet_name}` : "历史示例内容表"}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</small>
|
</small>
|
||||||
<div className="progress"><span style={{ width: `${progress}%` }} /></div>
|
<div className="progress"><span style={{ width: `${progress}%` }} /></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1491,7 +1672,13 @@ function TaskScopeList({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3>{task.name}</h3>
|
<h3>{task.name}</h3>
|
||||||
<span>{task.brand} · {formatDate(task.due_at)} 截止</span>
|
<div className="task-scope-subline">
|
||||||
|
<span>{task.brand} · {formatDate(task.due_at)} 截止</span>
|
||||||
|
<PlatformBadge platform={task.platform} compact />
|
||||||
|
<span className={`content-format-badge ${isScreenshotTask ? "screenshot" : task.content_format === "video" ? "video" : "image-text"}`}>
|
||||||
|
{isScreenshotTask ? "截图回收" : task.content_format === "video" ? "视频" : "图文"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`status-pill ${task.status}`}>{statusLabel(task.status)}</span>
|
<span className={`status-pill ${task.status}`}>{statusLabel(task.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1562,7 +1749,19 @@ function TaskDetailHeader({
|
|||||||
<div>
|
<div>
|
||||||
<p>{label}</p>
|
<p>{label}</p>
|
||||||
<h2>{task.name}</h2>
|
<h2>{task.name}</h2>
|
||||||
<span>{task.brand} · {task.quantity} {task.task_type === "screenshot_collect" ? "份" : "篇"} · {formatDate(task.due_at)} 截止</span>
|
<span className="platform-meta-line">
|
||||||
|
<span>{task.brand}</span>
|
||||||
|
{task.task_type === "screenshot_collect" ? (
|
||||||
|
<span>截图回收</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<PlatformBadge platform={task.platform} compact />
|
||||||
|
<span>{task.content_format === "video" ? "视频" : "图文"}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span>{task.quantity} {task.task_type === "screenshot_collect" ? "份" : "篇"}</span>
|
||||||
|
<span>{formatDate(task.due_at)} 截止</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{task.task_type !== "screenshot_collect" && task.source_url && (
|
{task.task_type !== "screenshot_collect" && task.source_url && (
|
||||||
<a href={task.source_url} target="_blank" rel="noreferrer">
|
<a href={task.source_url} target="_blank" rel="noreferrer">
|
||||||
@@ -1587,6 +1786,74 @@ function DistributionPage({
|
|||||||
onRelease: (distribution: Distribution) => Promise<boolean>;
|
onRelease: (distribution: Distribution) => Promise<boolean>;
|
||||||
}) {
|
}) {
|
||||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||||
|
const [taskQuery, setTaskQuery] = useState("");
|
||||||
|
const [brandFilter, setBrandFilter] = useState("");
|
||||||
|
const [contentTypeFilter, setContentTypeFilter] = useState("");
|
||||||
|
const [platformFilter, setPlatformFilter] = useState("");
|
||||||
|
const [openTaskFilter, setOpenTaskFilter] = useState<"brand" | "content" | "platform" | null>(null);
|
||||||
|
const taskFiltersRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const brandOptions = useMemo(
|
||||||
|
() => Array.from(new Set(tasks.map((task) => task.brand).filter(Boolean))).sort((a, b) => a.localeCompare(b, "zh-CN")),
|
||||||
|
[tasks],
|
||||||
|
);
|
||||||
|
const brandFilterOptions = useMemo(
|
||||||
|
() => [{ value: "", label: "全部品牌/项目" }, ...brandOptions.map((brand) => ({ value: brand, label: brand }))],
|
||||||
|
[brandOptions],
|
||||||
|
);
|
||||||
|
const contentTypeOptions = [
|
||||||
|
{ value: "", label: "全部内容类型" },
|
||||||
|
{ value: "图文", label: "图文" },
|
||||||
|
{ value: "视频", label: "视频" },
|
||||||
|
{ value: "截图回收", label: "截图回收" },
|
||||||
|
];
|
||||||
|
const platformOptions = [
|
||||||
|
{ value: "", label: "全部平台" },
|
||||||
|
{ value: "小红书", label: "小红书" },
|
||||||
|
{ value: "抖音", label: "抖音" },
|
||||||
|
];
|
||||||
|
const taskFilterControls = [
|
||||||
|
{ key: "brand" as const, label: "品牌/项目", value: brandFilter, options: brandFilterOptions },
|
||||||
|
{ key: "content" as const, label: "内容类型", value: contentTypeFilter, options: contentTypeOptions },
|
||||||
|
{ key: "platform" as const, label: "平台", value: platformFilter, options: platformOptions },
|
||||||
|
];
|
||||||
|
useEffect(() => {
|
||||||
|
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||||
|
if (!taskFiltersRef.current?.contains(event.target as Node)) {
|
||||||
|
setOpenTaskFilter(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", closeOnOutsideClick);
|
||||||
|
return () => document.removeEventListener("mousedown", closeOnOutsideClick);
|
||||||
|
}, []);
|
||||||
|
const setTaskFilterValue = (key: "brand" | "content" | "platform", value: string) => {
|
||||||
|
if (key === "brand") setBrandFilter(value);
|
||||||
|
if (key === "content") setContentTypeFilter(value);
|
||||||
|
if (key === "platform") setPlatformFilter(value);
|
||||||
|
};
|
||||||
|
const selectTaskFilter = (key: "brand" | "content" | "platform", value: string) => {
|
||||||
|
setTaskFilterValue(key, value);
|
||||||
|
setOpenTaskFilter(null);
|
||||||
|
};
|
||||||
|
const filteredTasks = useMemo(() => {
|
||||||
|
const query = taskQuery.trim().toLocaleLowerCase("zh-CN");
|
||||||
|
const brandQuery = brandFilter.trim().toLocaleLowerCase("zh-CN");
|
||||||
|
const contentTypeQuery = contentTypeFilter.trim().toLocaleLowerCase("zh-CN");
|
||||||
|
const platformQuery = platformFilter.trim().toLocaleLowerCase("zh-CN");
|
||||||
|
return tasks.filter((task) => {
|
||||||
|
const taskContentType = task.task_type === "screenshot_collect"
|
||||||
|
? "截图回收"
|
||||||
|
: task.content_format === "video" ? "视频" : "图文";
|
||||||
|
return (
|
||||||
|
(!query || task.name.toLocaleLowerCase("zh-CN").includes(query)) &&
|
||||||
|
(!brandQuery || task.brand.toLocaleLowerCase("zh-CN").includes(brandQuery)) &&
|
||||||
|
(!contentTypeQuery || taskContentType.toLocaleLowerCase("zh-CN").includes(contentTypeQuery)) &&
|
||||||
|
(!platformQuery || task.platform.toLocaleLowerCase("zh-CN").includes(platformQuery))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [brandFilter, contentTypeFilter, platformFilter, taskQuery, tasks]);
|
||||||
|
const hasTaskFilters = Boolean(
|
||||||
|
taskQuery.trim() || brandFilter.trim() || contentTypeFilter.trim() || platformFilter.trim(),
|
||||||
|
);
|
||||||
const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null);
|
const [releaseTarget, setReleaseTarget] = useState<Distribution | null>(null);
|
||||||
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
|
const selectedTask = tasks.find((task) => task.id === selectedTaskId);
|
||||||
if (!selectedTask) {
|
if (!selectedTask) {
|
||||||
@@ -1596,12 +1863,110 @@ function DistributionPage({
|
|||||||
<div><p className="eyebrow">按任务管理</p><h2>选择一个分发任务</h2></div>
|
<div><p className="eyebrow">按任务管理</p><h2>选择一个分发任务</h2></div>
|
||||||
<p>每个任务独立查看领取、发布和账号识别进度,不混排不同项目的笔记。</p>
|
<p>每个任务独立查看领取、发布和账号识别进度,不混排不同项目的笔记。</p>
|
||||||
</div>
|
</div>
|
||||||
<TaskScopeList
|
<section className="distribution-task-toolbar" aria-label="筛选分发任务">
|
||||||
tasks={tasks}
|
<label className="distribution-task-search">
|
||||||
distributions={distributions}
|
<span aria-hidden="true">⌕</span>
|
||||||
mode="distribution"
|
<input
|
||||||
onOpen={setSelectedTaskId}
|
type="search"
|
||||||
/>
|
value={taskQuery}
|
||||||
|
onChange={(event) => setTaskQuery(event.target.value)}
|
||||||
|
placeholder="搜索任务名称"
|
||||||
|
aria-label="按任务名称模糊搜索"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="distribution-task-filter-group" ref={taskFiltersRef}>
|
||||||
|
{taskFilterControls.map((filter) => {
|
||||||
|
const normalizedFilterValue = filter.value.trim().toLocaleLowerCase("zh-CN");
|
||||||
|
const visibleOptions = filter.options.filter((option) => (
|
||||||
|
!normalizedFilterValue ||
|
||||||
|
option.label.toLocaleLowerCase("zh-CN").includes(normalizedFilterValue)
|
||||||
|
));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`distribution-task-filter-combobox ${filter.key === "brand" ? "brand" : ""}`}
|
||||||
|
key={filter.key}
|
||||||
|
>
|
||||||
|
<div className="distribution-task-filter-input">
|
||||||
|
<span aria-hidden="true">⌕</span>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
role="combobox"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-expanded={openTaskFilter === filter.key}
|
||||||
|
aria-controls={`distribution-task-${filter.key}-menu`}
|
||||||
|
value={filter.value}
|
||||||
|
placeholder={`搜索${filter.label}`}
|
||||||
|
aria-label={`模糊搜索${filter.label}`}
|
||||||
|
onFocus={() => setOpenTaskFilter(filter.key)}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTaskFilterValue(filter.key, event.target.value);
|
||||||
|
setOpenTaskFilter(filter.key);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Escape") setOpenTaskFilter(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{openTaskFilter === filter.key ? (
|
||||||
|
<div
|
||||||
|
id={`distribution-task-${filter.key}-menu`}
|
||||||
|
className="distribution-task-filter-menu"
|
||||||
|
role="listbox"
|
||||||
|
aria-label={`选择${filter.label}`}
|
||||||
|
>
|
||||||
|
<div className="distribution-task-filter-menu-options">
|
||||||
|
{visibleOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={filter.value === option.value}
|
||||||
|
className={filter.value === option.value ? "selected" : ""}
|
||||||
|
onClick={() => selectTaskFilter(filter.key, option.value)}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{filter.value === option.value ? <small>✓</small> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{visibleOptions.length === 0 ? (
|
||||||
|
<span className="distribution-task-filter-empty">没有匹配项</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="distribution-task-clear"
|
||||||
|
disabled={!hasTaskFilters}
|
||||||
|
onClick={() => {
|
||||||
|
setTaskQuery("");
|
||||||
|
setBrandFilter("");
|
||||||
|
setContentTypeFilter("");
|
||||||
|
setPlatformFilter("");
|
||||||
|
setOpenTaskFilter(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清空筛选
|
||||||
|
</button>
|
||||||
|
<span className="distribution-task-count">共 <b>{filteredTasks.length}</b> 个任务</span>
|
||||||
|
</section>
|
||||||
|
{filteredTasks.length > 0 ? (
|
||||||
|
<TaskScopeList
|
||||||
|
tasks={filteredTasks}
|
||||||
|
distributions={distributions}
|
||||||
|
mode="distribution"
|
||||||
|
onOpen={setSelectedTaskId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<section className="distribution-task-empty">
|
||||||
|
<strong>没有符合条件的任务</strong>
|
||||||
|
<span>可以调整任务名称或清空筛选条件</span>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1739,7 +2104,7 @@ function DistributionTable({
|
|||||||
{item.account_nickname ? (
|
{item.account_nickname ? (
|
||||||
<div className="account-cell">
|
<div className="account-cell">
|
||||||
<div className={`avatar small ${avatarColor(item.account_nickname)}`}>{item.account_nickname.slice(0, 1)}</div>
|
<div className={`avatar small ${avatarColor(item.account_nickname)}`}>{item.account_nickname.slice(0, 1)}</div>
|
||||||
<div><strong>{item.account_nickname}</strong><span>{item.account_platform}</span></div>
|
<div><strong>{item.account_nickname}</strong><PlatformBadge platform={item.account_platform} compact /></div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="muted">回填链接后识别</span>
|
<span className="muted">回填链接后识别</span>
|
||||||
@@ -1986,21 +2351,19 @@ function ResourcesPage({
|
|||||||
sources: [
|
sources: [
|
||||||
...new Set(
|
...new Set(
|
||||||
[
|
[
|
||||||
...deliveries.map((item) => item.partner_name),
|
...deliveries
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
!item.claimant_name ||
|
||||||
|
item.partner_name !== item.claimant_name,
|
||||||
|
)
|
||||||
|
.map((item) => item.partner_name),
|
||||||
...(account.cooperation_source || "")
|
...(account.cooperation_source || "")
|
||||||
.split(/[、,,;;|]/)
|
.split(/[、,,;;|]/)
|
||||||
.map((item) => item.trim()),
|
.map((item) => item.trim()),
|
||||||
].filter(Boolean),
|
].filter(Boolean),
|
||||||
),
|
),
|
||||||
].sort((left, right) => left.localeCompare(right, "zh-CN")),
|
].sort((left, right) => left.localeCompare(right, "zh-CN")),
|
||||||
tags: [
|
|
||||||
...new Set(
|
|
||||||
(account.tags || "")
|
|
||||||
.split(/[、,,;;|]/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
partnerManagedOnly:
|
partnerManagedOnly:
|
||||||
deliveries.some((item) => item.delegation_bundle_id) &&
|
deliveries.some((item) => item.delegation_bundle_id) &&
|
||||||
deliveries.every((item) => item.delegation_bundle_id),
|
deliveries.every((item) => item.delegation_bundle_id),
|
||||||
@@ -2008,9 +2371,13 @@ function ResourcesPage({
|
|||||||
});
|
});
|
||||||
}, [accounts, distributions]);
|
}, [accounts, distributions]);
|
||||||
const platformOptions = useMemo(
|
const platformOptions = useMemo(
|
||||||
() =>
|
() => [
|
||||||
[...new Set(accounts.map((account) => account.platform).filter(Boolean))]
|
"小红书",
|
||||||
|
"抖音",
|
||||||
|
...[...new Set(accounts.map((account) => account.platform).filter(Boolean))]
|
||||||
|
.filter((platform) => platform !== "小红书" && platform !== "抖音")
|
||||||
.sort((left, right) => left.localeCompare(right, "zh-CN")),
|
.sort((left, right) => left.localeCompare(right, "zh-CN")),
|
||||||
|
],
|
||||||
[accounts],
|
[accounts],
|
||||||
);
|
);
|
||||||
const ipOptions = useMemo(
|
const ipOptions = useMemo(
|
||||||
@@ -2038,7 +2405,7 @@ function ResourcesPage({
|
|||||||
const ipKeyword = ipFilter.trim().toLocaleLowerCase("zh-CN");
|
const ipKeyword = ipFilter.trim().toLocaleLowerCase("zh-CN");
|
||||||
const sourceKeyword = sourceFilter.trim().toLocaleLowerCase("zh-CN");
|
const sourceKeyword = sourceFilter.trim().toLocaleLowerCase("zh-CN");
|
||||||
return resourceAccounts.filter(({ account, sources }) => {
|
return resourceAccounts.filter(({ account, sources }) => {
|
||||||
const searchable = `${account.nickname} ${account.public_account_id}`.toLocaleLowerCase(
|
const searchable = `${account.nickname} ${account.public_account_id} ${account.current_contact || ""} ${account.bio || ""} ${account.tags || ""}`.toLocaleLowerCase(
|
||||||
"zh-CN",
|
"zh-CN",
|
||||||
);
|
);
|
||||||
const ipLocation = (account.ip_location || "待识别").toLocaleLowerCase(
|
const ipLocation = (account.ip_location || "待识别").toLocaleLowerCase(
|
||||||
@@ -2145,7 +2512,7 @@ function ResourcesPage({
|
|||||||
key={platform}
|
key={platform}
|
||||||
onClick={() => setPlatformFilter(platform)}
|
onClick={() => setPlatformFilter(platform)}
|
||||||
>
|
>
|
||||||
{platform}
|
<PlatformBadge platform={platform} compact />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2155,9 +2522,9 @@ function ResourcesPage({
|
|||||||
<div className="resource-search">
|
<div className="resource-search">
|
||||||
<span aria-hidden="true">⌕</span>
|
<span aria-hidden="true">⌕</span>
|
||||||
<input
|
<input
|
||||||
aria-label="搜索账号名称或账号ID"
|
aria-label="搜索账号名称、账号ID或当前联系人"
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
placeholder="搜索账号名称 / 账号ID"
|
placeholder="搜索账号名称 / 账号ID / 当前联系人 / 标签"
|
||||||
type="search"
|
type="search"
|
||||||
value={query}
|
value={query}
|
||||||
/>
|
/>
|
||||||
@@ -2213,44 +2580,86 @@ function ResourcesPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="resource-grid">
|
<div className="resource-grid">
|
||||||
{filteredAccounts.map(({ account, sources, tags: tagList, partnerManagedOnly }) => (
|
{filteredAccounts.map(({ account, sources, partnerManagedOnly }) => {
|
||||||
<article className="resource-card" key={account.id}>
|
const profileLink = resourceProfileLink(account);
|
||||||
|
const accountTags = (account.tags || "")
|
||||||
|
.split(/[,,、;;|]/)
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 5);
|
||||||
|
const rawBio = (account.bio || "").trim();
|
||||||
|
const hasBio = Boolean(
|
||||||
|
rawBio &&
|
||||||
|
!/^(未填写简介|还没有简介|暂无简介|未填写|待识别)$/u.test(rawBio),
|
||||||
|
);
|
||||||
|
const accountIdLabel =
|
||||||
|
account.platform === "小红书"
|
||||||
|
? "小红书号"
|
||||||
|
: account.platform === "抖音"
|
||||||
|
? "抖音号"
|
||||||
|
: "账号";
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`resource-card ${account.platform === "小红书" ? "platform-xhs" : account.platform === "抖音" ? "platform-douyin" : "platform-other"}`}
|
||||||
|
key={account.id}
|
||||||
|
>
|
||||||
<div className="resource-card-head">
|
<div className="resource-card-head">
|
||||||
<div className={`avatar xlarge ${avatarColor(account.nickname)}`}>{account.nickname.slice(0, 1)}</div>
|
<div className={`avatar xlarge resource-profile-avatar ${avatarColor(account.nickname)}`}>{account.nickname.slice(0, 1)}</div>
|
||||||
<div><h3>{account.nickname}</h3><span>{account.platform} · {account.ip_location}</span></div>
|
<div className="resource-profile-main">
|
||||||
<span className="verified-dot">✓</span>
|
<div className="resource-name-line">
|
||||||
</div>
|
<h3>{account.nickname}</h3>
|
||||||
<div className="resource-account-id">
|
{account.gender === "男" && (
|
||||||
<span>{account.platform === "小红书" ? "小红书号" : account.platform === "抖音" ? "抖音号" : "账号"}</span>
|
<span className="gender-icon male" aria-label="男性" title="男性">♂</span>
|
||||||
<strong>{account.public_account_id || "待识别"}</strong>
|
)}
|
||||||
</div>
|
{account.gender === "女" && (
|
||||||
<div className="resource-metrics">
|
<span className="gender-icon female" aria-label="女性" title="女性">♀</span>
|
||||||
<div><span>粉丝</span><strong>{formatNumber(account.followers)}</strong></div>
|
)}
|
||||||
<div><span>合作发布</span><strong>{account.post_count}</strong></div>
|
</div>
|
||||||
</div>
|
<div className="resource-account-number">
|
||||||
<div className="resource-source">
|
<span>{accountIdLabel}</span>
|
||||||
<span>历史合作来源</span>
|
<strong>{account.public_account_id || "待识别"}</strong>
|
||||||
<div>
|
</div>
|
||||||
{sources.map((source) => <b key={source}>{source}</b>)}
|
<div className="resource-platform-line">
|
||||||
{partnerManagedOnly && (
|
<PlatformBadge platform={account.platform} compact />
|
||||||
<b className="partner-managed">合作社资源 · 不可直联</b>
|
<span className="resource-location">IP属地 · {account.ip_location || "待识别"}</span>
|
||||||
)}
|
</div>
|
||||||
</div>
|
<div className="resource-current-contact">
|
||||||
</div>
|
<span>当前联系人</span>
|
||||||
{tagList.length > 0 && (
|
<strong>{account.current_contact || "待补充"}</strong>
|
||||||
<div className="resource-tags">
|
|
||||||
<span>账号标签</span>
|
|
||||||
<div>
|
|
||||||
{tagList.map((tag) => <b key={tag}>{tag}</b>)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
<p className={`resource-bio ${hasBio ? "" : "empty"}`} title={hasBio ? rawBio : undefined}>
|
||||||
|
{hasBio ? rawBio : "暂无简介"}
|
||||||
|
</p>
|
||||||
|
<div className={`resource-tags ${accountTags.length === 0 ? "empty" : ""}`}>
|
||||||
|
<div>
|
||||||
|
{accountTags.length > 0
|
||||||
|
? accountTags.map((tag) => <b key={tag}>{tag}</b>)
|
||||||
|
: <b>待打标</b>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="resource-metrics" aria-label="账号数据">
|
||||||
|
<div><span>粉丝</span><strong>{formatNumber(account.followers)}</strong></div>
|
||||||
|
<div><span>合作发布</span><strong>{account.post_count}</strong></div>
|
||||||
|
<div className="resource-latest"><span>最近合作</span><strong>{formatDate(account.last_seen_at)}</strong></div>
|
||||||
|
</div>
|
||||||
<div className="resource-card-foot">
|
<div className="resource-card-foot">
|
||||||
<span>最近合作 {formatDate(account.last_seen_at)}</span>
|
<div className="resource-source">
|
||||||
{account.profile_url && <a href={account.profile_url} target="_blank" rel="noreferrer">查看主页 ↗</a>}
|
<span>合作来源</span>
|
||||||
|
<div>
|
||||||
|
{sources.map((source) => <b key={source}>{source}</b>)}
|
||||||
|
{partnerManagedOnly && (
|
||||||
|
<b className="partner-managed">合作社资源 · 不可直联</b>
|
||||||
|
)}
|
||||||
|
{sources.length === 0 && !partnerManagedOnly && <b className="empty">暂无</b>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{profileLink && <a href={profileLink.href} target="_blank" rel="noreferrer">{profileLink.label}</a>}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
{filteredAccounts.length === 0 && (
|
{filteredAccounts.length === 0 && (
|
||||||
<div className="resource-empty">
|
<div className="resource-empty">
|
||||||
@@ -2296,11 +2705,11 @@ function ResourcesPage({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<strong>{importFile ? importFile.name : "选择 Excel / CSV 文件"}</strong>
|
<strong>{importFile ? importFile.name : "选择 Excel / CSV 文件"}</strong>
|
||||||
<span>单次最多 100 个账号,文件不超过 5MB</span>
|
<span>单次最多 10,000 个账号,文件不超过 20MB</span>
|
||||||
</label>
|
</label>
|
||||||
{!importPreview && (
|
{!importPreview && (
|
||||||
<div className="import-template-note">
|
<div className="import-template-note">
|
||||||
<div><strong>还没有模板?</strong><span>只有账号链接必填;已有昵称、账号ID、IP属地、粉丝数、合作来源和标签可直接填写,系统只补全空缺字段。</span></div>
|
<div><strong>还没有模板?</strong><span>只有账号链接必填;性别、简介、标签等资料均可选填,多个标签请用逗号分隔且最多 5 个,系统只补全空缺字段。</span></div>
|
||||||
<a className="ghost-button" download href="/KOC资源导入模板.xlsx">下载模板</a>
|
<a className="ghost-button" download href="/KOC资源导入模板.xlsx">下载模板</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2319,9 +2728,9 @@ function ResourcesPage({
|
|||||||
{importPreview.rows.slice(0, 8).map((row) => (
|
{importPreview.rows.slice(0, 8).map((row) => (
|
||||||
<div className="import-preview-row" key={row.rowNumber}>
|
<div className="import-preview-row" key={row.rowNumber}>
|
||||||
<span>{row.rowNumber}</span>
|
<span>{row.rowNumber}</span>
|
||||||
<span><strong>{row.nickname || "—"}</strong><small>{row.platform || "未填写平台"}</small></span>
|
<span><strong>{row.nickname || "—"}</strong><small><PlatformBadge platform={row.platform} compact /></small></span>
|
||||||
<span>{row.publicAccountId || "主页识别"}</span>
|
<span>{row.publicAccountId || "主页识别"}</span>
|
||||||
<span><strong>{row.ipLocation}</strong><small>{row.cooperationSource || "未填写来源"}</small>{row.tags && <em>{row.tags}</em>}</span>
|
<span><strong>{row.ipLocation}</strong><small>{row.cooperationSource || "未填写来源"}</small></span>
|
||||||
<span className={`import-result ${row.action}`}>
|
<span className={`import-result ${row.action}`}>
|
||||||
{row.action === "create" ? "新增" : row.action === "update" ? "更新" : row.errors.join(";")}
|
{row.action === "create" ? "新增" : row.action === "update" ? "更新" : row.errors.join(";")}
|
||||||
</span>
|
</span>
|
||||||
@@ -2329,7 +2738,17 @@ function ResourcesPage({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{(importPreview.summary.total > 8 || importPreview.truncated) && (
|
{(importPreview.summary.total > 8 || importPreview.truncated) && (
|
||||||
<p className="import-preview-more">这里只预览前 8 行,确认导入时会处理全部数据。</p>
|
<p className="import-preview-more">这里只预览 8 行,异常数据会优先展示;确认导入时会处理全部有效数据。</p>
|
||||||
|
)}
|
||||||
|
{importPreview.summary.error > 0 && (
|
||||||
|
<p className="import-preview-warning">
|
||||||
|
{importPreview.summary.error} 条异常数据将自动跳过,不会导入;其余 {importPreview.summary.create + importPreview.summary.update} 个有效账号可正常导入。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{Boolean(importPreview.deferredEnrichment) && (
|
||||||
|
<p className="import-preview-more">
|
||||||
|
其中 {importPreview.deferredEnrichment} 个账号缺少公开资料;确认后会先导入资源库,再在后台逐步补全。
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -2349,7 +2768,7 @@ function ResourcesPage({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
disabled={importPreview.summary.error > 0 || importWorking}
|
disabled={importPreview.summary.create + importPreview.summary.update === 0 || importWorking}
|
||||||
onClick={() => void submitImport("commit")}
|
onClick={() => void submitImport("commit")}
|
||||||
>
|
>
|
||||||
{importWorking ? "导入中…" : `确认导入 ${importPreview.summary.create + importPreview.summary.update} 个账号`}
|
{importWorking ? "导入中…" : `确认导入 ${importPreview.summary.create + importPreview.summary.update} 个账号`}
|
||||||
@@ -2396,7 +2815,9 @@ function RecoveryPage({
|
|||||||
tasks,
|
tasks,
|
||||||
distributions,
|
distributions,
|
||||||
working,
|
working,
|
||||||
|
canUpdatePublishUrl,
|
||||||
onCollect,
|
onCollect,
|
||||||
|
onUpdatePublishUrl,
|
||||||
onSaveSchedule,
|
onSaveSchedule,
|
||||||
onUpload,
|
onUpload,
|
||||||
onFallback,
|
onFallback,
|
||||||
@@ -2408,7 +2829,9 @@ function RecoveryPage({
|
|||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
distributions: Distribution[];
|
distributions: Distribution[];
|
||||||
working: boolean;
|
working: boolean;
|
||||||
|
canUpdatePublishUrl: boolean;
|
||||||
onCollect: (distribution: Distribution) => void;
|
onCollect: (distribution: Distribution) => void;
|
||||||
|
onUpdatePublishUrl: (distribution: Distribution) => void;
|
||||||
onSaveSchedule: (
|
onSaveSchedule: (
|
||||||
taskId: string,
|
taskId: string,
|
||||||
startDate: string,
|
startDate: string,
|
||||||
@@ -2522,7 +2945,7 @@ function RecoveryPage({
|
|||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>数据回收队列</h2>
|
<h2>数据回收队列</h2>
|
||||||
<p>{taskDistributions.length} 篇已发布笔记 · 展示最近一次采集结果</p>
|
<p>{taskDistributions.length} 篇已发布作品 · 展示最近一次采集结果</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="recovery-panel-actions">
|
<div className="recovery-panel-actions">
|
||||||
<button
|
<button
|
||||||
@@ -2572,6 +2995,15 @@ function RecoveryPage({
|
|||||||
direction={recoverySort.direction}
|
direction={recoverySort.direction}
|
||||||
onSort={changeRecoverySort}
|
onSort={changeRecoverySort}
|
||||||
/>
|
/>
|
||||||
|
{selectedTask.platform === "抖音" && (
|
||||||
|
<SortableRecoveryHeader
|
||||||
|
label="转发"
|
||||||
|
column="shares"
|
||||||
|
activeColumn={recoverySort.column}
|
||||||
|
direction={recoverySort.direction}
|
||||||
|
onSort={changeRecoverySort}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<SortableRecoveryHeader
|
<SortableRecoveryHeader
|
||||||
label="评论"
|
label="评论"
|
||||||
column="comments"
|
column="comments"
|
||||||
@@ -2601,7 +3033,7 @@ function RecoveryPage({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{sortedTaskDistributions.map((item) => {
|
{sortedTaskDistributions.map((item) => {
|
||||||
const metrics = latestPublicMetrics(item);
|
const metrics = latestPublicMetrics(item);
|
||||||
const noteUrl = xhsPublishUrl(item.publish_url);
|
const noteUrl = publicPublishUrl(item.publish_url);
|
||||||
const hasMetrics = metrics.likes !== null;
|
const hasMetrics = metrics.likes !== null;
|
||||||
const collectionStatus =
|
const collectionStatus =
|
||||||
item.collection_status ||
|
item.collection_status ||
|
||||||
@@ -2618,17 +3050,17 @@ function RecoveryPage({
|
|||||||
href={noteUrl}
|
href={noteUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label={`打开小红书笔记:${item.content_title}`}
|
aria-label={`打开${selectedTask.platform}作品:${item.content_title}`}
|
||||||
title="打开小红书笔记"
|
title={`打开${selectedTask.platform}作品`}
|
||||||
>
|
>
|
||||||
<strong>{item.content_title}</strong>
|
<strong>{item.content_title}</strong>
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<strong>{item.content_title}</strong>
|
<strong>{item.content_title}</strong>
|
||||||
)}
|
)}
|
||||||
<span>
|
<span className="platform-meta-line">
|
||||||
{item.account_nickname || "待识别账号"}
|
<span>{item.account_nickname || "待识别账号"}</span>
|
||||||
{item.account_platform ? ` · ${item.account_platform}` : ""}
|
{item.account_platform && <PlatformBadge platform={item.account_platform} compact />}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2640,11 +3072,14 @@ function RecoveryPage({
|
|||||||
</td>
|
</td>
|
||||||
<td><div className="metric-cell"><strong>{formatNumber(metrics.likes)}</strong></div></td>
|
<td><div className="metric-cell"><strong>{formatNumber(metrics.likes)}</strong></div></td>
|
||||||
<td><div className="metric-cell"><strong>{formatNumber(metrics.collects)}</strong></div></td>
|
<td><div className="metric-cell"><strong>{formatNumber(metrics.collects)}</strong></div></td>
|
||||||
|
{selectedTask.platform === "抖音" && (
|
||||||
|
<td><div className="metric-cell"><strong>{formatNumber(metrics.shares)}</strong></div></td>
|
||||||
|
)}
|
||||||
<td><div className="metric-cell"><strong>{formatNumber(metrics.comments)}</strong></div></td>
|
<td><div className="metric-cell"><strong>{formatNumber(metrics.comments)}</strong></div></td>
|
||||||
<td>
|
<td>
|
||||||
<div className="metric-cell total">
|
<div className="metric-cell total">
|
||||||
<strong>{formatNumber(metrics.total)}</strong>
|
<strong>{formatNumber(metrics.total)}</strong>
|
||||||
<span>赞 + 藏 + 评</span>
|
<span>{selectedTask.platform === "抖音" ? "赞 + 藏 + 转 + 评" : "赞 + 藏 + 评"}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -2698,17 +3133,28 @@ function RecoveryPage({
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{item.screenshot_key && !hasCreatorMetrics(item) ? (
|
<div className="recovery-row-actions">
|
||||||
<button className="text-button" onClick={() => onFallback(item)}>手动填写</button>
|
{item.screenshot_key && !hasCreatorMetrics(item) ? (
|
||||||
) : (
|
<button className="text-button" onClick={() => onFallback(item)}>手动填写</button>
|
||||||
<button
|
) : (
|
||||||
className="collect-button"
|
<button
|
||||||
disabled={working}
|
className="collect-button"
|
||||||
onClick={() => onCollect(item)}
|
disabled={working || !noteUrl}
|
||||||
>
|
onClick={() => onCollect(item)}
|
||||||
立即采集
|
>
|
||||||
</button>
|
{noteUrl ? "立即采集" : "待填链接"}
|
||||||
)}
|
</button>
|
||||||
|
)}
|
||||||
|
{canUpdatePublishUrl && (
|
||||||
|
<button
|
||||||
|
className="publish-url-edit-button"
|
||||||
|
disabled={working}
|
||||||
|
onClick={() => onUpdatePublishUrl(item)}
|
||||||
|
>
|
||||||
|
{noteUrl ? "更新链接" : "填写链接"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { getRuntimeEnv } from "../../../lib/runtime-env";
|
|||||||
import { runInBackground } from "../../../lib/background";
|
import { runInBackground } from "../../../lib/background";
|
||||||
|
|
||||||
const env = getRuntimeEnv();
|
const env = getRuntimeEnv();
|
||||||
import { backfillAccountProfiles } from "../../../lib/account-enrichment-service";
|
import {
|
||||||
|
backfillAccountProfiles,
|
||||||
|
enrichDistributionAccount,
|
||||||
|
} from "../../../lib/account-enrichment-service";
|
||||||
import {
|
import {
|
||||||
ensureSchema,
|
ensureSchema,
|
||||||
getDashboardData,
|
getDashboardData,
|
||||||
@@ -42,6 +45,7 @@ import {
|
|||||||
type WecomBindings,
|
type WecomBindings,
|
||||||
} from "../../../lib/wecom-client";
|
} from "../../../lib/wecom-client";
|
||||||
import { isManagerRequest } from "../../../lib/user-auth";
|
import { isManagerRequest } from "../../../lib/user-auth";
|
||||||
|
import { extractPublishUrl } from "../../../lib/publish-url";
|
||||||
|
|
||||||
type ActionBody = {
|
type ActionBody = {
|
||||||
action?: string;
|
action?: string;
|
||||||
@@ -70,6 +74,14 @@ export async function POST(request: Request) {
|
|||||||
sheetName: source.sheetName,
|
sheetName: source.sheetName,
|
||||||
syncedAt: source.syncedAt,
|
syncedAt: source.syncedAt,
|
||||||
rowCount: source.rows.length,
|
rowCount: source.rows.length,
|
||||||
|
imageCount: source.rows.reduce(
|
||||||
|
(total, row) => total + row.images.length,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
videoCount: source.rows.reduce(
|
||||||
|
(total, row) => total + row.videos.length,
|
||||||
|
0,
|
||||||
|
),
|
||||||
columns: source.columns,
|
columns: source.columns,
|
||||||
preview: source.rows.slice(0, 3),
|
preview: source.rows.slice(0, 3),
|
||||||
});
|
});
|
||||||
@@ -79,6 +91,8 @@ export async function POST(request: Request) {
|
|||||||
const name = String(body.name ?? "").trim();
|
const name = String(body.name ?? "").trim();
|
||||||
const brand = String(body.brand ?? "").trim();
|
const brand = String(body.brand ?? "").trim();
|
||||||
const dueAt = String(body.dueAt ?? "").trim();
|
const dueAt = String(body.dueAt ?? "").trim();
|
||||||
|
const platform = body.platform === "抖音" ? "抖音" : "小红书";
|
||||||
|
const contentFormat = body.contentFormat === "video" ? "video" : "image_text";
|
||||||
if (!name || !brand || !dueAt) {
|
if (!name || !brand || !dueAt) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "请补全任务名称、品牌和截止日期" },
|
{ error: "请补全任务名称、品牌和截止日期" },
|
||||||
@@ -91,6 +105,8 @@ export async function POST(request: Request) {
|
|||||||
name,
|
name,
|
||||||
brand,
|
brand,
|
||||||
dueAt,
|
dueAt,
|
||||||
|
platform,
|
||||||
|
contentFormat,
|
||||||
},
|
},
|
||||||
env as unknown as FeishuBindings,
|
env as unknown as FeishuBindings,
|
||||||
);
|
);
|
||||||
@@ -153,6 +169,159 @@ export async function POST(request: Request) {
|
|||||||
db,
|
db,
|
||||||
String(body.distributionId ?? "").trim(),
|
String(body.distributionId ?? "").trim(),
|
||||||
);
|
);
|
||||||
|
} else if (body.action === "update_distribution_publish_url") {
|
||||||
|
if (!(await isManagerRequest(request))) return adminForbidden();
|
||||||
|
const distributionId = String(body.distributionId ?? "").trim();
|
||||||
|
if (!distributionId) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "作品记录不存在" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const current = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT d.id, d.task_id, d.partner_id, d.publish_url,
|
||||||
|
t.task_type, t.platform, t.collection_start_date, t.collection_days,
|
||||||
|
COALESCE(a.nickname, '待识别账号') AS account_nickname
|
||||||
|
FROM distributions d
|
||||||
|
JOIN tasks t ON t.id = d.task_id
|
||||||
|
LEFT JOIN accounts a ON a.id = d.account_id
|
||||||
|
WHERE d.id = ?`,
|
||||||
|
)
|
||||||
|
.bind(distributionId)
|
||||||
|
.first<{
|
||||||
|
id: string;
|
||||||
|
task_id: string;
|
||||||
|
partner_id: string;
|
||||||
|
publish_url: string | null;
|
||||||
|
task_type?: string | null;
|
||||||
|
platform: string;
|
||||||
|
collection_start_date: string | null;
|
||||||
|
collection_days: string;
|
||||||
|
account_nickname: string;
|
||||||
|
}>();
|
||||||
|
if (!current) {
|
||||||
|
return Response.json({ error: "作品记录不存在" }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (current.task_type === "screenshot_collect") {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "截图回收任务不需要填写发布链接" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const platform = current.platform === "抖音" ? "抖音" : "小红书";
|
||||||
|
const publishUrl = extractPublishUrl(
|
||||||
|
String(body.publishUrl ?? "").trim(),
|
||||||
|
platform,
|
||||||
|
);
|
||||||
|
if (!publishUrl) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: `请填写包含${platform}作品链接的发布内容` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (current.publish_url === publishUrl) {
|
||||||
|
return Response.json(await getDashboardData());
|
||||||
|
}
|
||||||
|
let collectionDays: number[] = [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(current.collection_days || "[]");
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
collectionDays = [...new Set(parsed.map(Number))]
|
||||||
|
.filter(
|
||||||
|
(day) =>
|
||||||
|
Number.isInteger(day) && day >= 1 && day <= 7,
|
||||||
|
)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
collectionDays = [];
|
||||||
|
}
|
||||||
|
const isScheduled = Boolean(
|
||||||
|
current.collection_start_date && collectionDays.length > 0,
|
||||||
|
);
|
||||||
|
const statements = [
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE distributions SET
|
||||||
|
publish_url = ?,
|
||||||
|
publish_time = CURRENT_TIMESTAMP,
|
||||||
|
status = 'published',
|
||||||
|
d2_likes = NULL,
|
||||||
|
d2_comments = NULL,
|
||||||
|
d2_collects = NULL,
|
||||||
|
d5_likes = NULL,
|
||||||
|
d5_comments = NULL,
|
||||||
|
d5_collects = NULL,
|
||||||
|
d7_likes = NULL,
|
||||||
|
d7_comments = NULL,
|
||||||
|
d7_collects = NULL,
|
||||||
|
latest_likes = NULL,
|
||||||
|
latest_comments = NULL,
|
||||||
|
latest_collects = NULL,
|
||||||
|
latest_shares = NULL,
|
||||||
|
collection_status = ?,
|
||||||
|
collection_status_description = ?,
|
||||||
|
collection_updated_at = NULL,
|
||||||
|
last_collection_day = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
publishUrl,
|
||||||
|
isScheduled ? "scheduled" : "pending",
|
||||||
|
isScheduled
|
||||||
|
? `管理员已更新链接,等待${collectionDays.length}个采集日`
|
||||||
|
: "管理员已更新链接,等待设置采集计划",
|
||||||
|
distributionId,
|
||||||
|
),
|
||||||
|
db
|
||||||
|
.prepare("DELETE FROM collection_runs WHERE distribution_id = ?")
|
||||||
|
.bind(distributionId),
|
||||||
|
];
|
||||||
|
if (!current.publish_url) {
|
||||||
|
statements.push(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE partners SET completed_total = completed_total + 1
|
||||||
|
WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(current.partner_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await db.batch(statements);
|
||||||
|
if (isScheduled && current.collection_start_date) {
|
||||||
|
await createCollectionRunTasks(
|
||||||
|
db,
|
||||||
|
current.task_id,
|
||||||
|
current.collection_start_date,
|
||||||
|
collectionDays,
|
||||||
|
);
|
||||||
|
runInBackground(
|
||||||
|
runDueScheduledCollections(
|
||||||
|
db,
|
||||||
|
Date.now(),
|
||||||
|
resolveCollectionMcpConfig(
|
||||||
|
env as unknown as CollectionMcpBindings,
|
||||||
|
),
|
||||||
|
"catchup",
|
||||||
|
current.task_id,
|
||||||
|
).catch(() => undefined),
|
||||||
|
"collection catchup after publish URL update",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
runInBackground(
|
||||||
|
enrichDistributionAccount(
|
||||||
|
db,
|
||||||
|
distributionId,
|
||||||
|
publishUrl,
|
||||||
|
current.account_nickname,
|
||||||
|
resolveCollectionMcpConfig(
|
||||||
|
env as unknown as CollectionMcpBindings,
|
||||||
|
),
|
||||||
|
).catch(() => undefined),
|
||||||
|
"account enrichment after publish URL update",
|
||||||
|
);
|
||||||
} else if (body.action === "save_collection_schedule") {
|
} else if (body.action === "save_collection_schedule") {
|
||||||
const taskId = String(body.taskId ?? "").trim();
|
const taskId = String(body.taskId ?? "").trim();
|
||||||
const startDate = String(body.startDate ?? "").trim();
|
const startDate = String(body.startDate ?? "").trim();
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ const toolOutputSchema = z.object({
|
|||||||
due_date: z.string(),
|
due_date: z.string(),
|
||||||
sheet_name: z.string(),
|
sheet_name: z.string(),
|
||||||
note_count: z.number().int().nonnegative(),
|
note_count: z.number().int().nonnegative(),
|
||||||
|
platform: z.enum(["小红书", "抖音"]),
|
||||||
|
content_format: z.enum(["image_text", "video"]),
|
||||||
claim_url: z.string().url(),
|
claim_url: z.string().url(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ function createServer(context: McpRequestContext) {
|
|||||||
{
|
{
|
||||||
title: "创建 KOC 分发任务",
|
title: "创建 KOC 分发任务",
|
||||||
description:
|
description:
|
||||||
"读取飞书电子表格中的标题、正文和配图,在 KOC LOOP 创建分发任务,并返回可直接发给 KOC 的领取链接。飞书表格若有多个工作表,链接必须包含目标 sheet 参数。",
|
"读取飞书电子表格中的标题、正文及图片或视频,在 KOC LOOP 创建小红书/抖音分发任务,并返回可直接发给 KOC 的领取链接。飞书表格若有多个工作表,链接必须包含目标 sheet 参数。",
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
feishu_url: z
|
feishu_url: z
|
||||||
.string()
|
.string()
|
||||||
@@ -74,6 +76,14 @@ function createServer(context: McpRequestContext) {
|
|||||||
.max(100)
|
.max(100)
|
||||||
.optional()
|
.optional()
|
||||||
.describe("品牌或项目名称;未提供时系统记录为“未设置项目”"),
|
.describe("品牌或项目名称;未提供时系统记录为“未设置项目”"),
|
||||||
|
platform: z
|
||||||
|
.enum(["小红书", "抖音"])
|
||||||
|
.optional()
|
||||||
|
.describe("发布平台,默认小红书"),
|
||||||
|
content_format: z
|
||||||
|
.enum(["image_text", "video"])
|
||||||
|
.optional()
|
||||||
|
.describe("内容形式:image_text 图文,video 视频;默认图文"),
|
||||||
}),
|
}),
|
||||||
outputSchema: toolOutputSchema,
|
outputSchema: toolOutputSchema,
|
||||||
annotations: {
|
annotations: {
|
||||||
@@ -83,7 +93,7 @@ function createServer(context: McpRequestContext) {
|
|||||||
openWorldHint: true,
|
openWorldHint: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ feishu_url, task_name, due_date, brand_project }) => {
|
async ({ feishu_url, task_name, due_date, brand_project, platform, content_format }) => {
|
||||||
try {
|
try {
|
||||||
const bindings = getBindings();
|
const bindings = getBindings();
|
||||||
const portalUrl = String(bindings.KOC_PORTAL_URL ?? "").trim();
|
const portalUrl = String(bindings.KOC_PORTAL_URL ?? "").trim();
|
||||||
@@ -96,6 +106,8 @@ function createServer(context: McpRequestContext) {
|
|||||||
name: task_name,
|
name: task_name,
|
||||||
brand: brand_project?.trim() || "未设置项目",
|
brand: brand_project?.trim() || "未设置项目",
|
||||||
dueAt: due_date,
|
dueAt: due_date,
|
||||||
|
platform: platform ?? "小红书",
|
||||||
|
contentFormat: content_format ?? "image_text",
|
||||||
},
|
},
|
||||||
bindings,
|
bindings,
|
||||||
{ deduplicate: true },
|
{ deduplicate: true },
|
||||||
@@ -108,6 +120,8 @@ function createServer(context: McpRequestContext) {
|
|||||||
due_date: result.dueAt,
|
due_date: result.dueAt,
|
||||||
sheet_name: result.sheetName,
|
sheet_name: result.sheetName,
|
||||||
note_count: result.noteCount,
|
note_count: result.noteCount,
|
||||||
|
platform: result.platform,
|
||||||
|
content_format: result.contentFormat,
|
||||||
claim_url: buildClaimUrl(portalUrl, result.shareToken),
|
claim_url: buildClaimUrl(portalUrl, result.shareToken),
|
||||||
};
|
};
|
||||||
const actionText = result.created ? "已创建" : "已找到相同任务";
|
const actionText = result.created ? "已创建" : "已找到相同任务";
|
||||||
@@ -115,7 +129,7 @@ function createServer(context: McpRequestContext) {
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: `${actionText}“${result.name}”,共 ${result.noteCount} 篇笔记。领取链接:${output.claim_url}`,
|
text: `${actionText}“${result.name}”,平台:${result.platform},内容形式:${result.contentFormat === "video" ? "视频" : "图文"},共 ${result.noteCount} 篇。领取链接:${output.claim_url}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
structuredContent: output,
|
structuredContent: output,
|
||||||
|
|||||||
750
app/api/partner-batch-workbook/route.ts
Normal file
@@ -0,0 +1,750 @@
|
|||||||
|
import { getRuntimeEnv } from "../../../lib/runtime-env";
|
||||||
|
import { runInBackground } from "../../../lib/background";
|
||||||
|
import type { DatabaseStatement } from "../../../lib/database";
|
||||||
|
import {
|
||||||
|
downloadFeishuMedia,
|
||||||
|
type FeishuBindings,
|
||||||
|
} from "../../../lib/feishu-client";
|
||||||
|
import {
|
||||||
|
ensureSchema,
|
||||||
|
getRawDb,
|
||||||
|
getUploadBucket,
|
||||||
|
hashText,
|
||||||
|
uid,
|
||||||
|
} from "../../../lib/mvp-db";
|
||||||
|
import {
|
||||||
|
PARTNER_BATCH_MAX_BYTES,
|
||||||
|
buildPartnerBatchWorkbookColumns,
|
||||||
|
parsePartnerBatchWorkbook,
|
||||||
|
resolvePartnerWorkbookOrigin,
|
||||||
|
} from "../../../lib/partner-batch-workbook";
|
||||||
|
import {
|
||||||
|
accountFromPublishLink,
|
||||||
|
} from "../../../lib/partner-utils";
|
||||||
|
import { extractPublishUrl } from "../../../lib/publish-url";
|
||||||
|
import {
|
||||||
|
partnerOptions,
|
||||||
|
withPartnerCors,
|
||||||
|
} from "../../../lib/partner-cors";
|
||||||
|
import {
|
||||||
|
buildRecoveryWorkbook,
|
||||||
|
type RecoveryWorkbookImage,
|
||||||
|
type RecoveryWorkbookRow,
|
||||||
|
} from "../../../lib/recovery-workbook";
|
||||||
|
import {
|
||||||
|
resolveCollectionMcpConfig,
|
||||||
|
type CollectionMcpBindings,
|
||||||
|
} from "../../../lib/mcp-collection-client";
|
||||||
|
import { enrichDistributionAccount } from "../../../lib/account-enrichment-service";
|
||||||
|
import {
|
||||||
|
createCollectionRunTasks,
|
||||||
|
runDueScheduledCollections,
|
||||||
|
} from "../../../lib/collection-service";
|
||||||
|
import { normalizeWorkbookImage } from "../../../lib/workbook-image";
|
||||||
|
|
||||||
|
const env = getRuntimeEnv();
|
||||||
|
|
||||||
|
type StoredAsset = {
|
||||||
|
index: number;
|
||||||
|
key: string;
|
||||||
|
fileToken?: string;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BatchRow = {
|
||||||
|
distribution_id: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
source_row: number | null;
|
||||||
|
image_assets: string;
|
||||||
|
video_assets: string;
|
||||||
|
publish_url: string | null;
|
||||||
|
publish_screenshot_key: string | null;
|
||||||
|
screenshot_key: string | null;
|
||||||
|
partner_id: string;
|
||||||
|
account_id: string | null;
|
||||||
|
claimant_name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TaskRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
task_type: string;
|
||||||
|
collection_start_date: string | null;
|
||||||
|
collection_days: string;
|
||||||
|
platform: "小红书" | "抖音";
|
||||||
|
content_format: "image_text" | "video";
|
||||||
|
};
|
||||||
|
|
||||||
|
type BatchAccess = {
|
||||||
|
task: TaskRow;
|
||||||
|
rows: BatchRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function textValue(value: string | null, maxLength = 100) {
|
||||||
|
return String(value ?? "").trim().slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFileName(value: string) {
|
||||||
|
return value.replace(/[\\/:*?"<>|]/g, " ").trim().slice(0, 60) || "领取笔记";
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactArrayBuffer(bytes: Uint8Array) {
|
||||||
|
const copy = new Uint8Array(bytes.byteLength);
|
||||||
|
copy.set(bytes);
|
||||||
|
return copy.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentTypeFromObject(object: { writeHttpMetadata(headers: Headers): void }) {
|
||||||
|
const headers = new Headers();
|
||||||
|
object.writeHttpMetadata(headers);
|
||||||
|
return headers.get("Content-Type") || "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAssets(
|
||||||
|
value: string,
|
||||||
|
prefixes = ["content-assets/", "task-assets/"],
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const assets = JSON.parse(value || "[]") as StoredAsset[];
|
||||||
|
return Array.isArray(assets)
|
||||||
|
? assets
|
||||||
|
.filter(
|
||||||
|
(asset) =>
|
||||||
|
Number.isInteger(Number(asset.index)) &&
|
||||||
|
Number(asset.index) > 0 &&
|
||||||
|
typeof asset.key === "string" &&
|
||||||
|
prefixes.some((prefix) => asset.key.startsWith(prefix)),
|
||||||
|
)
|
||||||
|
.map((asset) => ({ ...asset, index: Number(asset.index) }))
|
||||||
|
.sort((left, right) => left.index - right.index)
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadImage(
|
||||||
|
key: string,
|
||||||
|
description: string,
|
||||||
|
fileToken?: string,
|
||||||
|
width?: number | null,
|
||||||
|
height?: number | null,
|
||||||
|
compactSource = false,
|
||||||
|
) {
|
||||||
|
const bucket = getUploadBucket();
|
||||||
|
let object = await bucket.get(key);
|
||||||
|
if (!object && fileToken) {
|
||||||
|
const media = await downloadFeishuMedia(
|
||||||
|
fileToken,
|
||||||
|
env as unknown as FeishuBindings,
|
||||||
|
);
|
||||||
|
await bucket.put(key, media.bytes, {
|
||||||
|
httpMetadata: { contentType: media.contentType },
|
||||||
|
customMetadata: { source: "feishu-api" },
|
||||||
|
});
|
||||||
|
object = await bucket.get(key);
|
||||||
|
}
|
||||||
|
if (!object) return null;
|
||||||
|
return normalizeWorkbookImage(
|
||||||
|
{
|
||||||
|
bytes: new Uint8Array(await object.arrayBuffer()),
|
||||||
|
contentType: contentTypeFromObject(object),
|
||||||
|
description,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
} satisfies RecoveryWorkbookImage,
|
||||||
|
compactSource
|
||||||
|
? { maxDimension: 1600, outputFormat: "jpeg", jpegQuality: 82 }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findAccess(
|
||||||
|
taskToken: string,
|
||||||
|
claimToken: string,
|
||||||
|
delegationToken: string,
|
||||||
|
): Promise<BatchAccess | null> {
|
||||||
|
const db = getRawDb();
|
||||||
|
const task = delegationToken
|
||||||
|
? await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT t.id, t.name, t.task_type, t.collection_start_date, t.collection_days,
|
||||||
|
t.platform, t.content_format
|
||||||
|
FROM tasks t
|
||||||
|
JOIN delegation_bundles b ON b.task_id = t.id
|
||||||
|
WHERE b.share_token = ? AND b.status = 'active'`,
|
||||||
|
)
|
||||||
|
.bind(delegationToken)
|
||||||
|
.first<TaskRow>()
|
||||||
|
: await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT t.id, t.name, t.task_type, t.collection_start_date, t.collection_days,
|
||||||
|
t.platform, t.content_format
|
||||||
|
FROM tasks t
|
||||||
|
JOIN claims cl ON cl.task_id = t.id
|
||||||
|
WHERE t.share_token = ? AND cl.claim_token = ?`,
|
||||||
|
)
|
||||||
|
.bind(taskToken, claimToken)
|
||||||
|
.first<TaskRow>();
|
||||||
|
if (!task) return null;
|
||||||
|
const rows = delegationToken
|
||||||
|
? await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT d.id AS distribution_id, d.partner_id, d.account_id,
|
||||||
|
d.publish_url, d.publish_screenshot_key, d.screenshot_key,
|
||||||
|
c.title, c.body, c.source_row, c.image_assets, c.video_assets,
|
||||||
|
cl.claimant_name
|
||||||
|
FROM distributions d
|
||||||
|
JOIN contents c ON c.id = d.content_id
|
||||||
|
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
|
||||||
|
JOIN claims cl ON cl.id = d.claim_id
|
||||||
|
WHERE b.share_token = ? AND b.task_id = ? AND b.status = 'active'
|
||||||
|
ORDER BY d.claimed_at, d.id`,
|
||||||
|
)
|
||||||
|
.bind(delegationToken, task.id)
|
||||||
|
.all<BatchRow>()
|
||||||
|
: await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT d.id AS distribution_id, d.partner_id, d.account_id,
|
||||||
|
d.publish_url, d.publish_screenshot_key, d.screenshot_key,
|
||||||
|
c.title, c.body, c.source_row, c.image_assets, c.video_assets,
|
||||||
|
cl.claimant_name
|
||||||
|
FROM distributions d
|
||||||
|
JOIN contents c ON c.id = d.content_id
|
||||||
|
JOIN claims cl ON cl.id = d.claim_id
|
||||||
|
WHERE cl.claim_token = ? AND cl.task_id = ?
|
||||||
|
ORDER BY d.claimed_at, d.id`,
|
||||||
|
)
|
||||||
|
.bind(claimToken, task.id)
|
||||||
|
.all<BatchRow>();
|
||||||
|
return { task, rows: rows.results };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGet(request: Request) {
|
||||||
|
try {
|
||||||
|
await ensureSchema();
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const taskToken = textValue(url.searchParams.get("task"));
|
||||||
|
const claimToken = textValue(url.searchParams.get("claim"));
|
||||||
|
const delegationToken = textValue(url.searchParams.get("share"));
|
||||||
|
if (!delegationToken && (!taskToken || !claimToken)) {
|
||||||
|
return Response.json({ error: "领取凭证不完整" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const access = await findAccess(taskToken, claimToken, delegationToken);
|
||||||
|
if (!access) return Response.json({ error: "领取凭证无效" }, { status: 403 });
|
||||||
|
if (access.task.task_type !== "content_publish") {
|
||||||
|
return Response.json({ error: "当前任务不支持笔记批量回填" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const maxSourceImages = Math.max(
|
||||||
|
0,
|
||||||
|
...access.rows.map((row) => parseAssets(row.image_assets).length),
|
||||||
|
);
|
||||||
|
const maxSourceVideos = Math.max(
|
||||||
|
0,
|
||||||
|
...access.rows.map(
|
||||||
|
(row) => parseAssets(row.video_assets, ["content-videos/"]).length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const columns = buildPartnerBatchWorkbookColumns({
|
||||||
|
contentFormat: access.task.content_format,
|
||||||
|
maxSourceImages,
|
||||||
|
maxSourceVideos,
|
||||||
|
});
|
||||||
|
const downloadOrigin = resolvePartnerWorkbookOrigin(
|
||||||
|
request,
|
||||||
|
env.APP_ORIGIN,
|
||||||
|
);
|
||||||
|
const workbookRows: RecoveryWorkbookRow[] = [];
|
||||||
|
for (let rowIndex = 0; rowIndex < access.rows.length; rowIndex += 1) {
|
||||||
|
const row = access.rows[rowIndex];
|
||||||
|
const images: RecoveryWorkbookRow["images"] = [];
|
||||||
|
const hyperlinks: NonNullable<RecoveryWorkbookRow["hyperlinks"]> = [];
|
||||||
|
const assets =
|
||||||
|
columns.sourceImageCount > 0 ? parseAssets(row.image_assets) : [];
|
||||||
|
for (let index = 0; index < assets.length; index += 1) {
|
||||||
|
const asset = assets[index];
|
||||||
|
const image = await loadImage(
|
||||||
|
asset.key,
|
||||||
|
`${row.title} 原图${asset.index}`,
|
||||||
|
asset.fileToken,
|
||||||
|
asset.width,
|
||||||
|
asset.height,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (image) {
|
||||||
|
images.push({
|
||||||
|
column: columns.sourceImageStartColumn + index,
|
||||||
|
image,
|
||||||
|
maxWidth: 160,
|
||||||
|
maxHeight: 118,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (row.publish_screenshot_key) {
|
||||||
|
const image = await loadImage(
|
||||||
|
row.publish_screenshot_key,
|
||||||
|
`${row.title} 笔记截图`,
|
||||||
|
);
|
||||||
|
if (image) {
|
||||||
|
images.push({ column: columns.publishScreenshotColumn, image });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (row.screenshot_key) {
|
||||||
|
const image = await loadImage(
|
||||||
|
row.screenshot_key,
|
||||||
|
`${row.title} 数据分析截图`,
|
||||||
|
);
|
||||||
|
if (image) {
|
||||||
|
images.push({ column: columns.creatorScreenshotColumn, image });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const videoAssets = parseAssets(row.video_assets, ["content-videos/"]);
|
||||||
|
for (let index = 0; index < videoAssets.length; index += 1) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
distribution: row.distribution_id,
|
||||||
|
index: String(videoAssets[index].index),
|
||||||
|
kind: "video",
|
||||||
|
download: "1",
|
||||||
|
});
|
||||||
|
if (delegationToken) params.set("share", delegationToken);
|
||||||
|
else {
|
||||||
|
params.set("task", taskToken);
|
||||||
|
params.set("claim", claimToken);
|
||||||
|
}
|
||||||
|
hyperlinks.push({
|
||||||
|
column: columns.sourceVideoStartColumn + index,
|
||||||
|
url: `${downloadOrigin}/api/partner-image?${params}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
workbookRows.push({
|
||||||
|
cells: [
|
||||||
|
rowIndex + 1,
|
||||||
|
row.title,
|
||||||
|
row.body,
|
||||||
|
...Array.from({ length: columns.sourceImageCount }, () => ""),
|
||||||
|
...Array.from(
|
||||||
|
{ length: columns.sourceVideoCount },
|
||||||
|
(_, index) => (index < videoAssets.length ? `下载视频${index + 1}` : ""),
|
||||||
|
),
|
||||||
|
row.publish_url || "",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
row.distribution_id,
|
||||||
|
row.publish_screenshot_key || "",
|
||||||
|
row.screenshot_key || "",
|
||||||
|
],
|
||||||
|
images,
|
||||||
|
hyperlinks: [
|
||||||
|
...hyperlinks,
|
||||||
|
...(row.publish_url
|
||||||
|
? [{ column: columns.publishUrlColumn, url: row.publish_url }]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const workbook = buildRecoveryWorkbook({
|
||||||
|
sheetName: "批量回填",
|
||||||
|
headers: columns.headers,
|
||||||
|
columnWidths: columns.columnWidths,
|
||||||
|
rows: workbookRows,
|
||||||
|
hiddenColumns: [
|
||||||
|
columns.systemColumn,
|
||||||
|
columns.systemColumn + 1,
|
||||||
|
columns.systemColumn + 2,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const fileName = `${safeFileName(access.task.name)}-批量回填.xlsx`;
|
||||||
|
return new Response(exactArrayBuffer(workbook), {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
"Content-Type":
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"Content-Disposition": `attachment; filename="koc-batch.xlsx"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: error instanceof Error ? error.message : "导出失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extensionForImage(image: { fileName: string; contentType: string }) {
|
||||||
|
const fromName = image.fileName.split(".").at(-1)?.replace(/[^a-zA-Z0-9]/g, "");
|
||||||
|
if (fromName) return fromName.toLowerCase() === "jpeg" ? "jpg" : fromName;
|
||||||
|
return image.contentType.includes("png")
|
||||||
|
? "png"
|
||||||
|
: image.contentType.includes("webp")
|
||||||
|
? "webp"
|
||||||
|
: image.contentType.includes("gif")
|
||||||
|
? "gif"
|
||||||
|
: "jpg";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeImportedImage(
|
||||||
|
kind: "publish" | "creator",
|
||||||
|
distributionId: string,
|
||||||
|
image: { bytes: Uint8Array; contentType: string; fileName: string },
|
||||||
|
) {
|
||||||
|
const prefix = kind === "publish" ? "publish-evidence" : "creator-center";
|
||||||
|
const key = `${prefix}/${distributionId}/${uid("sheet")}.${extensionForImage(image)}`;
|
||||||
|
await getUploadBucket().put(key, image.bytes, {
|
||||||
|
httpMetadata: { contentType: image.contentType },
|
||||||
|
customMetadata: { source: "partner-batch-workbook" },
|
||||||
|
});
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isDifferentFromStoredImage(
|
||||||
|
existingKey: string | null,
|
||||||
|
image: { bytes: Uint8Array },
|
||||||
|
) {
|
||||||
|
if (!existingKey) return true;
|
||||||
|
const stored = await getUploadBucket().get(existingKey);
|
||||||
|
if (!stored) return true;
|
||||||
|
const storedBytes = new Uint8Array(await stored.arrayBuffer());
|
||||||
|
if (storedBytes.byteLength !== image.bytes.byteLength) return true;
|
||||||
|
for (let index = 0; index < storedBytes.byteLength; index += 1) {
|
||||||
|
if (storedBytes[index] !== image.bytes[index]) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePost(request: Request) {
|
||||||
|
try {
|
||||||
|
await ensureSchema();
|
||||||
|
const form = await request.formData();
|
||||||
|
const file = form.get("file");
|
||||||
|
if (!(file instanceof File) || file.size === 0) {
|
||||||
|
return Response.json({ error: "请选择填写完成的Excel表" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (file.size > PARTNER_BATCH_MAX_BYTES) {
|
||||||
|
return Response.json({ error: "批量回填表不能超过80MB" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!/\.xlsx$/i.test(file.name)) {
|
||||||
|
return Response.json({ error: "仅支持系统导出的 .xlsx 表格" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const taskToken = textValue(String(form.get("taskToken") ?? ""));
|
||||||
|
const claimToken = textValue(String(form.get("claimToken") ?? ""));
|
||||||
|
const delegationToken = textValue(String(form.get("delegationToken") ?? ""));
|
||||||
|
const access = await findAccess(taskToken, claimToken, delegationToken);
|
||||||
|
if (!access) return Response.json({ error: "领取凭证无效" }, { status: 403 });
|
||||||
|
if (access.task.task_type !== "content_publish") {
|
||||||
|
return Response.json({ error: "当前任务不支持笔记批量回填" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const importedRows = parsePartnerBatchWorkbook(await file.arrayBuffer());
|
||||||
|
const assignmentById = new Map(
|
||||||
|
access.rows.map((row) => [row.distribution_id, row]),
|
||||||
|
);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const errors: string[] = [];
|
||||||
|
const prepared = importedRows.map((row) => {
|
||||||
|
const assignment = assignmentById.get(row.distributionId);
|
||||||
|
if (!assignment) {
|
||||||
|
errors.push(`第${row.spreadsheetRow}行不属于当前领取批次,请重新导出表格`);
|
||||||
|
} else if (seen.has(row.distributionId)) {
|
||||||
|
errors.push(`第${row.spreadsheetRow}行笔记重复`);
|
||||||
|
} else if (row.title && row.title !== assignment.title) {
|
||||||
|
errors.push(`第${row.spreadsheetRow}行标题已被修改,请重新导出表格`);
|
||||||
|
}
|
||||||
|
seen.add(row.distributionId);
|
||||||
|
const publishUrl = row.publishUrl
|
||||||
|
? extractPublishUrl(row.publishUrl, access.task.platform)
|
||||||
|
: assignment?.publish_url || "";
|
||||||
|
if (row.publishUrl && !publishUrl) {
|
||||||
|
errors.push(`第${row.spreadsheetRow}行发布链接不是有效的${access.task.platform}作品链接`);
|
||||||
|
}
|
||||||
|
const hasPublishScreenshot = Boolean(
|
||||||
|
assignment?.publish_screenshot_key || row.publishScreenshot,
|
||||||
|
);
|
||||||
|
if (publishUrl && !hasPublishScreenshot) {
|
||||||
|
errors.push(`第${row.spreadsheetRow}行填写了发布链接,请同时插入笔记截图`);
|
||||||
|
}
|
||||||
|
if (row.publishScreenshot && !publishUrl) {
|
||||||
|
errors.push(`第${row.spreadsheetRow}行插入了笔记截图,请同时填写发布链接`);
|
||||||
|
}
|
||||||
|
if (row.creatorScreenshot && !publishUrl) {
|
||||||
|
errors.push(`第${row.spreadsheetRow}行需先回填发布链接,再补数据分析截图`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
imported: row,
|
||||||
|
assignment,
|
||||||
|
publishUrl,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: errors.slice(0, 8).join(";"), errors },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getRawDb();
|
||||||
|
let updatedRows = 0;
|
||||||
|
let publishedCount = 0;
|
||||||
|
let analysisScreenshotCount = 0;
|
||||||
|
let noteScreenshotCount = 0;
|
||||||
|
let publishUrlChangedCount = 0;
|
||||||
|
const enrichments: Array<{ id: string; url: string; nickname: string }> = [];
|
||||||
|
for (const item of prepared) {
|
||||||
|
const assignment = item.assignment!;
|
||||||
|
let publishScreenshotKey = assignment.publish_screenshot_key;
|
||||||
|
let creatorScreenshotKey = assignment.screenshot_key;
|
||||||
|
const hasNewPublishScreenshot = item.imported.publishScreenshot
|
||||||
|
? await isDifferentFromStoredImage(
|
||||||
|
assignment.publish_screenshot_key,
|
||||||
|
item.imported.publishScreenshot,
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
const hasNewCreatorScreenshot = item.imported.creatorScreenshot
|
||||||
|
? await isDifferentFromStoredImage(
|
||||||
|
assignment.screenshot_key,
|
||||||
|
item.imported.creatorScreenshot,
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
if (hasNewPublishScreenshot && item.imported.publishScreenshot) {
|
||||||
|
publishScreenshotKey = await storeImportedImage(
|
||||||
|
"publish",
|
||||||
|
assignment.distribution_id,
|
||||||
|
item.imported.publishScreenshot,
|
||||||
|
);
|
||||||
|
noteScreenshotCount += 1;
|
||||||
|
}
|
||||||
|
if (hasNewCreatorScreenshot && item.imported.creatorScreenshot) {
|
||||||
|
creatorScreenshotKey = await storeImportedImage(
|
||||||
|
"creator",
|
||||||
|
assignment.distribution_id,
|
||||||
|
item.imported.creatorScreenshot,
|
||||||
|
);
|
||||||
|
analysisScreenshotCount += 1;
|
||||||
|
}
|
||||||
|
const statements: DatabaseStatement[] = [];
|
||||||
|
if (publishScreenshotKey !== assignment.publish_screenshot_key) {
|
||||||
|
statements.push(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE distributions SET publish_screenshot_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(publishScreenshotKey, assignment.distribution_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (creatorScreenshotKey !== assignment.screenshot_key) {
|
||||||
|
statements.push(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE distributions SET screenshot_key = ?,
|
||||||
|
ocr_status = CASE WHEN exposure IS NOT NULL AND views IS NOT NULL THEN ocr_status ELSE 'uploaded' END,
|
||||||
|
updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(creatorScreenshotKey, assignment.distribution_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (item.publishUrl && item.publishUrl !== assignment.publish_url) {
|
||||||
|
const isReplacement = Boolean(assignment.publish_url);
|
||||||
|
const account = accountFromPublishLink(
|
||||||
|
item.publishUrl,
|
||||||
|
access.task.platform,
|
||||||
|
);
|
||||||
|
if (!account) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: `第${item.imported.spreadsheetRow}行发布链接格式不正确` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const matchedAccount = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id FROM accounts
|
||||||
|
WHERE platform = ? AND platform_uid = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
)
|
||||||
|
.bind(account.platform, account.platformUid)
|
||||||
|
.first<{ id: string }>();
|
||||||
|
const accountId =
|
||||||
|
matchedAccount?.id ||
|
||||||
|
`account-${hashText(`${account.platform}:${account.platformUid}`)}`;
|
||||||
|
statements.push(
|
||||||
|
...(isReplacement
|
||||||
|
? [
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE distributions SET
|
||||||
|
d2_likes = NULL, d2_comments = NULL, d2_collects = NULL,
|
||||||
|
d5_likes = NULL, d5_comments = NULL, d5_collects = NULL,
|
||||||
|
d7_likes = NULL, d7_comments = NULL, d7_collects = NULL,
|
||||||
|
latest_likes = NULL, latest_comments = NULL,
|
||||||
|
latest_collects = NULL, latest_shares = NULL,
|
||||||
|
collection_status = 'pending',
|
||||||
|
collection_status_description = '批量回填已更新链接,等待重新采集',
|
||||||
|
collection_updated_at = NULL, last_collection_day = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(assignment.distribution_id),
|
||||||
|
db
|
||||||
|
.prepare("DELETE FROM collection_runs WHERE distribution_id = ?")
|
||||||
|
.bind(assignment.distribution_id),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO accounts
|
||||||
|
(id, platform, platform_uid, nickname, profile_url,
|
||||||
|
current_contact, post_count)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 1)
|
||||||
|
ON CONFLICT(platform, platform_uid) DO UPDATE SET
|
||||||
|
nickname = excluded.nickname,
|
||||||
|
profile_url = excluded.profile_url,
|
||||||
|
current_contact = CASE
|
||||||
|
WHEN excluded.current_contact != ''
|
||||||
|
THEN excluded.current_contact
|
||||||
|
ELSE accounts.current_contact
|
||||||
|
END,
|
||||||
|
last_seen_at = CURRENT_TIMESTAMP`,
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
accountId,
|
||||||
|
account.platform,
|
||||||
|
account.platformUid,
|
||||||
|
account.nickname,
|
||||||
|
account.profileUrl,
|
||||||
|
assignment.claimant_name,
|
||||||
|
),
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE distributions SET account_id = ?, publish_url = ?,
|
||||||
|
publish_time = COALESCE(publish_time, CURRENT_TIMESTAMP),
|
||||||
|
status = 'published', updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(accountId, item.publishUrl, assignment.distribution_id),
|
||||||
|
);
|
||||||
|
statements.push(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE accounts SET post_count = (
|
||||||
|
SELECT COUNT(*) FROM distributions WHERE account_id = ?
|
||||||
|
) WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(accountId, accountId),
|
||||||
|
);
|
||||||
|
if (assignment.account_id && assignment.account_id !== accountId) {
|
||||||
|
statements.push(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE accounts SET post_count = (
|
||||||
|
SELECT COUNT(*) FROM distributions WHERE account_id = ?
|
||||||
|
) WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(assignment.account_id, assignment.account_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isReplacement) publishUrlChangedCount += 1;
|
||||||
|
if (!assignment.publish_url) {
|
||||||
|
statements.push(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE partners SET completed_total = completed_total + 1 WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(assignment.partner_id),
|
||||||
|
);
|
||||||
|
publishedCount += 1;
|
||||||
|
}
|
||||||
|
enrichments.push({
|
||||||
|
id: assignment.distribution_id,
|
||||||
|
url: item.publishUrl,
|
||||||
|
nickname: account.nickname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (statements.length > 0) {
|
||||||
|
await db.batch(statements);
|
||||||
|
updatedRows += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(publishedCount > 0 || publishUrlChangedCount > 0) &&
|
||||||
|
access.task.collection_start_date &&
|
||||||
|
access.task.collection_days !== "[]"
|
||||||
|
) {
|
||||||
|
let collectionDays: number[] = [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(access.task.collection_days);
|
||||||
|
if (Array.isArray(parsed)) collectionDays = parsed.map(Number);
|
||||||
|
} catch {
|
||||||
|
collectionDays = [];
|
||||||
|
}
|
||||||
|
if (collectionDays.length > 0) {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE distributions SET collection_status = 'scheduled',
|
||||||
|
collection_status_description = ?
|
||||||
|
WHERE task_id = ? AND publish_url IS NOT NULL AND publish_url != ''`,
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
`已安排${collectionDays.length}个采集日,每日09:00执行`,
|
||||||
|
access.task.id,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
await createCollectionRunTasks(
|
||||||
|
db,
|
||||||
|
access.task.id,
|
||||||
|
access.task.collection_start_date,
|
||||||
|
collectionDays,
|
||||||
|
);
|
||||||
|
runInBackground(
|
||||||
|
runDueScheduledCollections(
|
||||||
|
db,
|
||||||
|
Date.now(),
|
||||||
|
resolveCollectionMcpConfig(
|
||||||
|
env as unknown as CollectionMcpBindings,
|
||||||
|
),
|
||||||
|
"catchup",
|
||||||
|
access.task.id,
|
||||||
|
).catch(() => undefined),
|
||||||
|
"collection catchup after batch publish update",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const enrichment of enrichments) {
|
||||||
|
runInBackground(
|
||||||
|
enrichDistributionAccount(
|
||||||
|
db,
|
||||||
|
enrichment.id,
|
||||||
|
enrichment.url,
|
||||||
|
enrichment.nickname,
|
||||||
|
resolveCollectionMcpConfig(env as unknown as CollectionMcpBindings),
|
||||||
|
).catch(() => undefined),
|
||||||
|
"batch distribution account enrichment",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Response.json({
|
||||||
|
imported: true,
|
||||||
|
updatedRows,
|
||||||
|
publishedCount,
|
||||||
|
publishUrlChangedCount,
|
||||||
|
noteScreenshotCount,
|
||||||
|
analysisScreenshotCount,
|
||||||
|
skippedRows: importedRows.length - updatedRows,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: error instanceof Error ? error.message : "批量回填失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
return withPartnerCors(request, await handleGet(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
return withPartnerCors(request, await handlePost(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function OPTIONS(request: Request) {
|
||||||
|
return partnerOptions(request);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
withPartnerCors,
|
withPartnerCors,
|
||||||
} from "../../../lib/partner-cors";
|
} from "../../../lib/partner-cors";
|
||||||
import { parseResultScreenshotKeys } from "../../../lib/result-screenshots";
|
import { parseResultScreenshotKeys } from "../../../lib/result-screenshots";
|
||||||
|
import { hasMp4FileSignature } from "../../../lib/video-file";
|
||||||
|
|
||||||
const env = getRuntimeEnv();
|
const env = getRuntimeEnv();
|
||||||
|
|
||||||
@@ -26,7 +27,11 @@ function textValue(value: string | null, maxLength = 100) {
|
|||||||
return String(value ?? "").trim().slice(0, maxLength);
|
return String(value ?? "").trim().slice(0, maxLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findAsset(value: string, imageIndex: number) {
|
function findAsset(
|
||||||
|
value: string,
|
||||||
|
imageIndex: number,
|
||||||
|
prefixes = ["content-assets/", "task-assets/"],
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const assets = JSON.parse(value) as StoredAsset[];
|
const assets = JSON.parse(value) as StoredAsset[];
|
||||||
return Array.isArray(assets)
|
return Array.isArray(assets)
|
||||||
@@ -34,8 +39,7 @@ function findAsset(value: string, imageIndex: number) {
|
|||||||
(asset) =>
|
(asset) =>
|
||||||
asset.index === imageIndex &&
|
asset.index === imageIndex &&
|
||||||
typeof asset.key === "string" &&
|
typeof asset.key === "string" &&
|
||||||
(asset.key.startsWith("content-assets/") ||
|
prefixes.some((prefix) => asset.key.startsWith(prefix)) &&
|
||||||
asset.key.startsWith("task-assets/")) &&
|
|
||||||
(asset.fileToken === undefined ||
|
(asset.fileToken === undefined ||
|
||||||
typeof asset.fileToken === "string"),
|
typeof asset.fileToken === "string"),
|
||||||
)
|
)
|
||||||
@@ -55,18 +59,20 @@ async function handleGet(request: Request) {
|
|||||||
const distributionId = textValue(url.searchParams.get("distribution"));
|
const distributionId = textValue(url.searchParams.get("distribution"));
|
||||||
const imageIndex = Number(url.searchParams.get("index"));
|
const imageIndex = Number(url.searchParams.get("index"));
|
||||||
const imageKind = textValue(url.searchParams.get("kind"), 20);
|
const imageKind = textValue(url.searchParams.get("kind"), 20);
|
||||||
|
const downloadRequested = url.searchParams.get("download") === "1";
|
||||||
if (
|
if (
|
||||||
(!delegationToken && (!taskToken || !claimToken)) ||
|
(!delegationToken && (!taskToken || !claimToken)) ||
|
||||||
!distributionId ||
|
!distributionId ||
|
||||||
!Number.isInteger(imageIndex) ||
|
!Number.isInteger(imageIndex) ||
|
||||||
imageIndex < 1
|
imageIndex < 1
|
||||||
) {
|
) {
|
||||||
return Response.json({ error: "图片链接不完整" }, { status: 400 });
|
return Response.json({ error: "素材链接不完整" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const row = delegationToken
|
const row = delegationToken
|
||||||
? await getRawDb()
|
? await getRawDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT c.image_assets,
|
`SELECT c.image_assets,
|
||||||
|
c.video_assets,
|
||||||
d.result_screenshot_key,
|
d.result_screenshot_key,
|
||||||
d.publish_screenshot_key,
|
d.publish_screenshot_key,
|
||||||
d.screenshot_key
|
d.screenshot_key
|
||||||
@@ -80,6 +86,7 @@ async function handleGet(request: Request) {
|
|||||||
.bind(distributionId, delegationToken)
|
.bind(distributionId, delegationToken)
|
||||||
.first<{
|
.first<{
|
||||||
image_assets: string;
|
image_assets: string;
|
||||||
|
video_assets: string;
|
||||||
result_screenshot_key: string | null;
|
result_screenshot_key: string | null;
|
||||||
publish_screenshot_key: string | null;
|
publish_screenshot_key: string | null;
|
||||||
screenshot_key: string | null;
|
screenshot_key: string | null;
|
||||||
@@ -87,6 +94,7 @@ async function handleGet(request: Request) {
|
|||||||
: await getRawDb()
|
: await getRawDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT c.image_assets,
|
`SELECT c.image_assets,
|
||||||
|
c.video_assets,
|
||||||
d.result_screenshot_key,
|
d.result_screenshot_key,
|
||||||
d.publish_screenshot_key,
|
d.publish_screenshot_key,
|
||||||
d.screenshot_key
|
d.screenshot_key
|
||||||
@@ -102,6 +110,7 @@ async function handleGet(request: Request) {
|
|||||||
.bind(distributionId, claimToken, taskToken)
|
.bind(distributionId, claimToken, taskToken)
|
||||||
.first<{
|
.first<{
|
||||||
image_assets: string;
|
image_assets: string;
|
||||||
|
video_assets: string;
|
||||||
result_screenshot_key: string | null;
|
result_screenshot_key: string | null;
|
||||||
publish_screenshot_key: string | null;
|
publish_screenshot_key: string | null;
|
||||||
screenshot_key: string | null;
|
screenshot_key: string | null;
|
||||||
@@ -124,36 +133,82 @@ async function handleGet(request: Request) {
|
|||||||
? row?.screenshot_key?.startsWith("creator-center/")
|
? row?.screenshot_key?.startsWith("creator-center/")
|
||||||
? { index: 1, key: row.screenshot_key }
|
? { index: 1, key: row.screenshot_key }
|
||||||
: undefined
|
: undefined
|
||||||
|
: imageKind === "video"
|
||||||
|
? row
|
||||||
|
? findAsset(row.video_assets, imageIndex, ["content-videos/"])
|
||||||
|
: undefined
|
||||||
: row
|
: row
|
||||||
? findAsset(row.image_assets, imageIndex)
|
? findAsset(row.image_assets, imageIndex)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (!asset?.key) {
|
if (!asset?.key) {
|
||||||
return Response.json({ error: "没有找到这张图片" }, { status: 404 });
|
return Response.json({ error: "没有找到这个素材" }, { status: 404 });
|
||||||
}
|
}
|
||||||
const bucket = getUploadBucket();
|
const bucket = getUploadBucket();
|
||||||
let object = await bucket.get(asset.key);
|
let object = await bucket.get(asset.key);
|
||||||
if (!object && asset.fileToken) {
|
let objectBytes = object ? await object.arrayBuffer() : null;
|
||||||
|
const invalidStoredVideo =
|
||||||
|
imageKind === "video" &&
|
||||||
|
objectBytes !== null &&
|
||||||
|
!hasMp4FileSignature(objectBytes);
|
||||||
|
if ((!object || invalidStoredVideo) && asset.fileToken) {
|
||||||
const media = await downloadFeishuMedia(
|
const media = await downloadFeishuMedia(
|
||||||
asset.fileToken,
|
asset.fileToken,
|
||||||
env as unknown as FeishuBindings,
|
env as unknown as FeishuBindings,
|
||||||
|
fetch,
|
||||||
|
{
|
||||||
|
maxBytes: imageKind === "video" ? 500 * 1024 * 1024 : undefined,
|
||||||
|
label: imageKind === "video" ? "视频" : "图片",
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
if (imageKind === "video" && !hasMp4FileSignature(media.bytes)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "源视频不是可下载的 MP4 文件,请重新上传 MP4 视频" },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
|
}
|
||||||
await bucket.put(asset.key, media.bytes, {
|
await bucket.put(asset.key, media.bytes, {
|
||||||
httpMetadata: { contentType: media.contentType },
|
httpMetadata: {
|
||||||
|
contentType: imageKind === "video" ? "video/mp4" : media.contentType,
|
||||||
|
},
|
||||||
customMetadata: { source: "feishu-api" },
|
customMetadata: { source: "feishu-api" },
|
||||||
});
|
});
|
||||||
object = await bucket.get(asset.key);
|
object = await bucket.get(asset.key);
|
||||||
|
objectBytes = object ? await object.arrayBuffer() : media.bytes;
|
||||||
}
|
}
|
||||||
if (!object) {
|
if (!object || !objectBytes) {
|
||||||
return Response.json({ error: "图片同步失败,请稍后重试" }, { status: 404 });
|
return Response.json({ error: "素材同步失败,请稍后重试" }, { status: 404 });
|
||||||
|
}
|
||||||
|
if (imageKind === "video" && !hasMp4FileSignature(objectBytes)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "已存储的视频不是有效的 MP4 文件,请联系运营重新同步" },
|
||||||
|
{ status: 422 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
object.writeHttpMetadata(headers);
|
object.writeHttpMetadata(headers);
|
||||||
headers.set("Cache-Control", "private, max-age=3600");
|
const isMutableEvidence =
|
||||||
headers.set("Content-Disposition", `inline; filename="note-${imageIndex}"`);
|
imageKind === "publish" || imageKind === "creator";
|
||||||
return new Response(await object.arrayBuffer(), { headers });
|
headers.set(
|
||||||
|
"Cache-Control",
|
||||||
|
isMutableEvidence ? "private, no-store" : "private, max-age=3600",
|
||||||
|
);
|
||||||
|
if (imageKind === "video") {
|
||||||
|
headers.set("Content-Type", "video/mp4");
|
||||||
|
}
|
||||||
|
headers.set("Content-Length", String(objectBytes.byteLength));
|
||||||
|
headers.set("X-Content-Type-Options", "nosniff");
|
||||||
|
const fileName =
|
||||||
|
imageKind === "video"
|
||||||
|
? `video-${imageIndex}.mp4`
|
||||||
|
: `image-${imageIndex}`;
|
||||||
|
headers.set(
|
||||||
|
"Content-Disposition",
|
||||||
|
`${downloadRequested ? "attachment" : "inline"}; filename="${fileName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
||||||
|
);
|
||||||
|
return new Response(objectBytes, { headers });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: error instanceof Error ? error.message : "图片读取失败" },
|
{ error: error instanceof Error ? error.message : "素材读取失败" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import type { DatabaseStatement } from "../../../lib/database";
|
|||||||
|
|
||||||
const env = getRuntimeEnv();
|
const env = getRuntimeEnv();
|
||||||
import { enrichDistributionAccount } from "../../../lib/account-enrichment-service";
|
import { enrichDistributionAccount } from "../../../lib/account-enrichment-service";
|
||||||
import { createCollectionRunTasks } from "../../../lib/collection-service";
|
import {
|
||||||
|
createCollectionRunTasks,
|
||||||
|
runDueScheduledCollections,
|
||||||
|
} from "../../../lib/collection-service";
|
||||||
import {
|
import {
|
||||||
resolveCollectionMcpConfig,
|
resolveCollectionMcpConfig,
|
||||||
type CollectionMcpBindings,
|
type CollectionMcpBindings,
|
||||||
@@ -17,8 +20,8 @@ import {
|
|||||||
} from "../../../lib/mvp-db";
|
} from "../../../lib/mvp-db";
|
||||||
import {
|
import {
|
||||||
accountFromPublishLink,
|
accountFromPublishLink,
|
||||||
extractXhsPublishUrl,
|
|
||||||
} from "../../../lib/partner-utils";
|
} from "../../../lib/partner-utils";
|
||||||
|
import { extractPublishUrl } from "../../../lib/publish-url";
|
||||||
import { parseClaimantIdentifier } from "../../../lib/claimant-identifier";
|
import { parseClaimantIdentifier } from "../../../lib/claimant-identifier";
|
||||||
import {
|
import {
|
||||||
partnerOptions,
|
partnerOptions,
|
||||||
@@ -101,23 +104,28 @@ function publicImageAssets(value: unknown): ImageAsset[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PartnerTask = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
quantity: number;
|
||||||
|
claimed_quantity: number;
|
||||||
|
due_at: string;
|
||||||
|
status: string;
|
||||||
|
task_type: string;
|
||||||
|
platform: string;
|
||||||
|
content_format: string;
|
||||||
|
};
|
||||||
|
|
||||||
async function findTask(taskToken: string) {
|
async function findTask(taskToken: string) {
|
||||||
return getRawDb()
|
return getRawDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status, task_type
|
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status,
|
||||||
|
task_type, platform, content_format
|
||||||
FROM tasks WHERE share_token = ?`,
|
FROM tasks WHERE share_token = ?`,
|
||||||
)
|
)
|
||||||
.bind(taskToken)
|
.bind(taskToken)
|
||||||
.first<{
|
.first<PartnerTask>();
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
brand: string;
|
|
||||||
quantity: number;
|
|
||||||
claimed_quantity: number;
|
|
||||||
due_at: string;
|
|
||||||
status: string;
|
|
||||||
task_type: string;
|
|
||||||
}>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findDelegationAccess(delegationToken: string) {
|
async function findDelegationAccess(delegationToken: string) {
|
||||||
@@ -132,6 +140,8 @@ async function findDelegationAccess(delegationToken: string) {
|
|||||||
t.due_at,
|
t.due_at,
|
||||||
t.status,
|
t.status,
|
||||||
t.task_type,
|
t.task_type,
|
||||||
|
t.platform,
|
||||||
|
t.content_format,
|
||||||
b.id AS bundle_id,
|
b.id AS bundle_id,
|
||||||
b.label AS bundle_label,
|
b.label AS bundle_label,
|
||||||
b.quantity AS bundle_quantity,
|
b.quantity AS bundle_quantity,
|
||||||
@@ -141,15 +151,7 @@ async function findDelegationAccess(delegationToken: string) {
|
|||||||
WHERE b.share_token = ? AND b.status = 'active'`,
|
WHERE b.share_token = ? AND b.status = 'active'`,
|
||||||
)
|
)
|
||||||
.bind(delegationToken)
|
.bind(delegationToken)
|
||||||
.first<{
|
.first<PartnerTask & {
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
brand: string;
|
|
||||||
quantity: number;
|
|
||||||
claimed_quantity: number;
|
|
||||||
due_at: string;
|
|
||||||
status: string;
|
|
||||||
task_type: string;
|
|
||||||
bundle_id: string;
|
bundle_id: string;
|
||||||
bundle_label: string;
|
bundle_label: string;
|
||||||
bundle_quantity: number;
|
bundle_quantity: number;
|
||||||
@@ -172,13 +174,15 @@ async function findAccessibleAssignment(
|
|||||||
d.publish_screenshot_key,
|
d.publish_screenshot_key,
|
||||||
d.screenshot_key,
|
d.screenshot_key,
|
||||||
d.result_screenshot_key,
|
d.result_screenshot_key,
|
||||||
d.result_submitted_at`;
|
d.result_submitted_at,
|
||||||
|
c.claimant_name`;
|
||||||
if (delegationToken) {
|
if (delegationToken) {
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`${select}
|
`${select}
|
||||||
FROM distributions d
|
FROM distributions d
|
||||||
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
|
JOIN delegation_bundles b ON b.id = d.delegation_bundle_id
|
||||||
|
JOIN claims c ON c.id = d.claim_id
|
||||||
WHERE d.id = ?
|
WHERE d.id = ?
|
||||||
AND b.share_token = ?
|
AND b.share_token = ?
|
||||||
AND b.task_id = ?
|
AND b.task_id = ?
|
||||||
@@ -194,6 +198,7 @@ async function findAccessibleAssignment(
|
|||||||
screenshot_key: string | null;
|
screenshot_key: string | null;
|
||||||
result_screenshot_key: string | null;
|
result_screenshot_key: string | null;
|
||||||
result_submitted_at: string | null;
|
result_submitted_at: string | null;
|
||||||
|
claimant_name: string;
|
||||||
}>();
|
}>();
|
||||||
}
|
}
|
||||||
if (!claimToken) return null;
|
if (!claimToken) return null;
|
||||||
@@ -214,6 +219,7 @@ async function findAccessibleAssignment(
|
|||||||
screenshot_key: string | null;
|
screenshot_key: string | null;
|
||||||
result_screenshot_key: string | null;
|
result_screenshot_key: string | null;
|
||||||
result_submitted_at: string | null;
|
result_submitted_at: string | null;
|
||||||
|
claimant_name: string;
|
||||||
}>();
|
}>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +290,7 @@ async function handleGet(request: Request) {
|
|||||||
c.body,
|
c.body,
|
||||||
c.source_row,
|
c.source_row,
|
||||||
c.image_assets,
|
c.image_assets,
|
||||||
|
c.video_assets,
|
||||||
a.nickname AS account_nickname,
|
a.nickname AS account_nickname,
|
||||||
b.id AS delegation_bundle_id,
|
b.id AS delegation_bundle_id,
|
||||||
b.label AS delegation_label
|
b.label AS delegation_label
|
||||||
@@ -326,7 +333,9 @@ async function handleGet(request: Request) {
|
|||||||
assignments: assignments.results.map((assignment) => ({
|
assignments: assignments.results.map((assignment) => ({
|
||||||
...assignment,
|
...assignment,
|
||||||
images: publicImageAssets(assignment.image_assets),
|
images: publicImageAssets(assignment.image_assets),
|
||||||
|
videos: publicImageAssets(assignment.video_assets),
|
||||||
image_assets: undefined,
|
image_assets: undefined,
|
||||||
|
video_assets: undefined,
|
||||||
})),
|
})),
|
||||||
delegations: delegations.results,
|
delegations: delegations.results,
|
||||||
};
|
};
|
||||||
@@ -349,6 +358,7 @@ async function handleGet(request: Request) {
|
|||||||
c.body,
|
c.body,
|
||||||
c.source_row,
|
c.source_row,
|
||||||
c.image_assets,
|
c.image_assets,
|
||||||
|
c.video_assets,
|
||||||
a.nickname AS account_nickname
|
a.nickname AS account_nickname
|
||||||
FROM distributions d
|
FROM distributions d
|
||||||
JOIN contents c ON c.id = d.content_id
|
JOIN contents c ON c.id = d.content_id
|
||||||
@@ -366,7 +376,9 @@ async function handleGet(request: Request) {
|
|||||||
assignments: assignments.results.map((assignment) => ({
|
assignments: assignments.results.map((assignment) => ({
|
||||||
...assignment,
|
...assignment,
|
||||||
images: publicImageAssets(assignment.image_assets),
|
images: publicImageAssets(assignment.image_assets),
|
||||||
|
videos: publicImageAssets(assignment.video_assets),
|
||||||
image_assets: undefined,
|
image_assets: undefined,
|
||||||
|
video_assets: undefined,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -379,6 +391,8 @@ async function handleGet(request: Request) {
|
|||||||
dueAt: task.due_at,
|
dueAt: task.due_at,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
type: task.task_type,
|
type: task.task_type,
|
||||||
|
platform: task.platform,
|
||||||
|
contentFormat: task.content_format,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
name: task.name,
|
name: task.name,
|
||||||
@@ -388,6 +402,8 @@ async function handleGet(request: Request) {
|
|||||||
dueAt: task.due_at,
|
dueAt: task.due_at,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
type: task.task_type,
|
type: task.task_type,
|
||||||
|
platform: task.platform,
|
||||||
|
contentFormat: task.content_format,
|
||||||
availableQuantity: available?.count ?? 0,
|
availableQuantity: available?.count ?? 0,
|
||||||
},
|
},
|
||||||
claim,
|
claim,
|
||||||
@@ -798,14 +814,15 @@ async function handlePost(request: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const publishUrl = extractXhsPublishUrl(publishInput);
|
const platform = task.platform === "抖音" ? "抖音" : "小红书";
|
||||||
|
const publishUrl = extractPublishUrl(publishInput, platform);
|
||||||
if (!publishUrl) {
|
if (!publishUrl) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "请粘贴包含小红书长链或短链的分享内容" },
|
{ error: `请粘贴包含${platform}作品链接的分享内容` },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const account = accountFromPublishLink(publishUrl);
|
const account = accountFromPublishLink(publishUrl, platform);
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return Response.json({ error: "发布链接格式不正确" }, { status: 400 });
|
return Response.json({ error: "发布链接格式不正确" }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -816,28 +833,69 @@ async function handlePost(request: Request) {
|
|||||||
delegationToken,
|
delegationToken,
|
||||||
);
|
);
|
||||||
if (!assignment) {
|
if (!assignment) {
|
||||||
return Response.json({ error: "笔记与领取凭证不匹配" }, { status: 403 });
|
return Response.json({ error: "作品与领取凭证不匹配" }, { status: 403 });
|
||||||
}
|
}
|
||||||
if (!assignment.publish_screenshot_key) {
|
if (!assignment.publish_screenshot_key) {
|
||||||
return Response.json({ error: "请先上传发布截图" }, { status: 400 });
|
return Response.json({ error: "请先上传发布截图" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const reuseExistingAccount =
|
const reuseExistingAccount =
|
||||||
assignment.publish_url === publishUrl && assignment.account_id;
|
assignment.publish_url === publishUrl && assignment.account_id;
|
||||||
|
const matchedAccount = reuseExistingAccount
|
||||||
|
? null
|
||||||
|
: await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id FROM accounts
|
||||||
|
WHERE platform = ? AND platform_uid = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
)
|
||||||
|
.bind(account.platform, account.platformUid)
|
||||||
|
.first<{ id: string }>();
|
||||||
const accountId =
|
const accountId =
|
||||||
reuseExistingAccount ||
|
reuseExistingAccount ||
|
||||||
|
matchedAccount?.id ||
|
||||||
`account-${hashText(`${account.platform}:${account.platformUid}`)}`;
|
`account-${hashText(`${account.platform}:${account.platformUid}`)}`;
|
||||||
const statements: DatabaseStatement[] = [];
|
const statements: DatabaseStatement[] = [];
|
||||||
|
const publishUrlChanged = Boolean(
|
||||||
|
assignment.publish_url && assignment.publish_url !== publishUrl,
|
||||||
|
);
|
||||||
|
if (publishUrlChanged) {
|
||||||
|
statements.push(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE distributions SET
|
||||||
|
d2_likes = NULL, d2_comments = NULL, d2_collects = NULL,
|
||||||
|
d5_likes = NULL, d5_comments = NULL, d5_collects = NULL,
|
||||||
|
d7_likes = NULL, d7_comments = NULL, d7_collects = NULL,
|
||||||
|
latest_likes = NULL, latest_comments = NULL,
|
||||||
|
latest_collects = NULL, latest_shares = NULL,
|
||||||
|
collection_status = 'pending',
|
||||||
|
collection_status_description = '发布链接已更新,等待重新采集',
|
||||||
|
collection_updated_at = NULL, last_collection_day = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(assignment.id),
|
||||||
|
db
|
||||||
|
.prepare("DELETE FROM collection_runs WHERE distribution_id = ?")
|
||||||
|
.bind(assignment.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!reuseExistingAccount) {
|
if (!reuseExistingAccount) {
|
||||||
statements.push(
|
statements.push(
|
||||||
db
|
db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO accounts
|
`INSERT INTO accounts
|
||||||
(id, platform, platform_uid, nickname, profile_url, post_count)
|
(id, platform, platform_uid, nickname, profile_url,
|
||||||
VALUES (?, ?, ?, ?, ?, 1)
|
current_contact, post_count)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 1)
|
||||||
ON CONFLICT(platform, platform_uid) DO UPDATE SET
|
ON CONFLICT(platform, platform_uid) DO UPDATE SET
|
||||||
nickname = excluded.nickname,
|
nickname = excluded.nickname,
|
||||||
profile_url = excluded.profile_url,
|
profile_url = excluded.profile_url,
|
||||||
post_count = accounts.post_count + ?,
|
current_contact = CASE
|
||||||
|
WHEN excluded.current_contact != ''
|
||||||
|
THEN excluded.current_contact
|
||||||
|
ELSE accounts.current_contact
|
||||||
|
END,
|
||||||
last_seen_at = CURRENT_TIMESTAMP`,
|
last_seen_at = CURRENT_TIMESTAMP`,
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
@@ -846,7 +904,7 @@ async function handlePost(request: Request) {
|
|||||||
account.platformUid,
|
account.platformUid,
|
||||||
account.nickname,
|
account.nickname,
|
||||||
account.profileUrl,
|
account.profileUrl,
|
||||||
assignment.publish_url ? 0 : 1,
|
assignment.claimant_name,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -863,6 +921,26 @@ async function handlePost(request: Request) {
|
|||||||
)
|
)
|
||||||
.bind(accountId, publishUrl, assignment.id),
|
.bind(accountId, publishUrl, assignment.id),
|
||||||
);
|
);
|
||||||
|
statements.push(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE accounts SET post_count = (
|
||||||
|
SELECT COUNT(*) FROM distributions WHERE account_id = ?
|
||||||
|
) WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(accountId, accountId),
|
||||||
|
);
|
||||||
|
if (assignment.account_id && assignment.account_id !== accountId) {
|
||||||
|
statements.push(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE accounts SET post_count = (
|
||||||
|
SELECT COUNT(*) FROM distributions WHERE account_id = ?
|
||||||
|
) WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(assignment.account_id, assignment.account_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!assignment.publish_url) {
|
if (!assignment.publish_url) {
|
||||||
statements.push(
|
statements.push(
|
||||||
db
|
db
|
||||||
@@ -896,26 +974,46 @@ async function handlePost(request: Request) {
|
|||||||
collectionDays = [];
|
collectionDays = [];
|
||||||
}
|
}
|
||||||
if (collectionDays.length > 0) {
|
if (collectionDays.length > 0) {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE distributions SET collection_status = 'scheduled',
|
||||||
|
collection_status_description = ? WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
`已安排${collectionDays.length}个采集日,每日09:00执行`,
|
||||||
|
assignment.id,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
await createCollectionRunTasks(
|
await createCollectionRunTasks(
|
||||||
db,
|
db,
|
||||||
task.id,
|
task.id,
|
||||||
collectionSchedule.collection_start_date,
|
collectionSchedule.collection_start_date,
|
||||||
collectionDays,
|
collectionDays,
|
||||||
);
|
);
|
||||||
|
runInBackground(
|
||||||
|
runDueScheduledCollections(
|
||||||
|
db,
|
||||||
|
Date.now(),
|
||||||
|
resolveCollectionMcpConfig(
|
||||||
|
env as unknown as CollectionMcpBindings,
|
||||||
|
),
|
||||||
|
"catchup",
|
||||||
|
task.id,
|
||||||
|
).catch(() => undefined),
|
||||||
|
"collection catchup after partner publish update",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (account.platform === "小红书") {
|
const enrichment = enrichDistributionAccount(
|
||||||
const enrichment = enrichDistributionAccount(
|
db,
|
||||||
db,
|
assignment.id,
|
||||||
assignment.id,
|
publishUrl,
|
||||||
publishUrl,
|
account.nickname,
|
||||||
account.nickname,
|
resolveCollectionMcpConfig(
|
||||||
resolveCollectionMcpConfig(
|
env as unknown as CollectionMcpBindings,
|
||||||
env as unknown as CollectionMcpBindings,
|
),
|
||||||
),
|
).catch(() => undefined);
|
||||||
).catch(() => undefined);
|
runInBackground(enrichment, "distribution account enrichment");
|
||||||
runInBackground(enrichment, "distribution account enrichment");
|
|
||||||
}
|
|
||||||
return Response.json({ ok: true });
|
return Response.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type RecoveryWorkbookRow,
|
type RecoveryWorkbookRow,
|
||||||
} from "../../../lib/recovery-workbook";
|
} from "../../../lib/recovery-workbook";
|
||||||
import { consumeMcpExportToken } from "../../../lib/mcp-export-token";
|
import { consumeMcpExportToken } from "../../../lib/mcp-export-token";
|
||||||
|
import { normalizeWorkbookImage } from "../../../lib/workbook-image";
|
||||||
|
|
||||||
const env = getRuntimeEnv();
|
const env = getRuntimeEnv();
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ type ExportRow = {
|
|||||||
latest_likes: number | null;
|
latest_likes: number | null;
|
||||||
latest_comments: number | null;
|
latest_comments: number | null;
|
||||||
latest_collects: number | null;
|
latest_collects: number | null;
|
||||||
|
latest_shares: number | null;
|
||||||
collection_status: string | null;
|
collection_status: string | null;
|
||||||
collection_status_description: string | null;
|
collection_status_description: string | null;
|
||||||
collection_updated_at: string | null;
|
collection_updated_at: string | null;
|
||||||
@@ -121,12 +123,16 @@ function latestMetrics(row: ExportRow) {
|
|||||||
const likes = row.latest_likes ?? legacyLikes;
|
const likes = row.latest_likes ?? legacyLikes;
|
||||||
const comments = row.latest_comments ?? legacyComments;
|
const comments = row.latest_comments ?? legacyComments;
|
||||||
const collects = row.latest_collects ?? legacyCollects;
|
const collects = row.latest_collects ?? legacyCollects;
|
||||||
|
const shares = row.latest_shares;
|
||||||
return {
|
return {
|
||||||
likes,
|
likes,
|
||||||
comments,
|
comments,
|
||||||
collects,
|
collects,
|
||||||
|
shares,
|
||||||
total:
|
total:
|
||||||
likes === null ? null : likes + (comments ?? 0) + (collects ?? 0),
|
likes === null
|
||||||
|
? null
|
||||||
|
: likes + (comments ?? 0) + (collects ?? 0) + (shares ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,13 +187,13 @@ async function loadImage(reference: ImageReference) {
|
|||||||
object = await bucket.get(reference.key);
|
object = await bucket.get(reference.key);
|
||||||
}
|
}
|
||||||
if (!object) return null;
|
if (!object) return null;
|
||||||
return {
|
return normalizeWorkbookImage({
|
||||||
bytes: new Uint8Array(await object.arrayBuffer()),
|
bytes: new Uint8Array(await object.arrayBuffer()),
|
||||||
contentType: contentTypeFromObject(object),
|
contentType: contentTypeFromObject(object),
|
||||||
width: reference.width,
|
width: reference.width,
|
||||||
height: reference.height,
|
height: reference.height,
|
||||||
description: reference.description,
|
description: reference.description,
|
||||||
} satisfies RecoveryWorkbookImage;
|
} satisfies RecoveryWorkbookImage);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadImages(references: ImageReference[]) {
|
async function loadImages(references: ImageReference[]) {
|
||||||
@@ -236,9 +242,9 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
const db = getRawDb();
|
const db = getRawDb();
|
||||||
const task = await db
|
const task = await db
|
||||||
.prepare("SELECT id, name, brand FROM tasks WHERE id = ?")
|
.prepare("SELECT id, name, brand, platform FROM tasks WHERE id = ?")
|
||||||
.bind(taskId)
|
.bind(taskId)
|
||||||
.first<{ id: string; name: string; brand: string }>();
|
.first<{ id: string; name: string; brand: string; platform: string }>();
|
||||||
if (!task) {
|
if (!task) {
|
||||||
return Response.json({ error: "没有找到这个任务" }, { status: 404 });
|
return Response.json({ error: "没有找到这个任务" }, { status: 404 });
|
||||||
}
|
}
|
||||||
@@ -269,6 +275,7 @@ export async function GET(request: Request) {
|
|||||||
d.latest_likes,
|
d.latest_likes,
|
||||||
d.latest_comments,
|
d.latest_comments,
|
||||||
d.latest_collects,
|
d.latest_collects,
|
||||||
|
d.latest_shares,
|
||||||
d.collection_status,
|
d.collection_status,
|
||||||
d.collection_status_description,
|
d.collection_status_description,
|
||||||
d.collection_updated_at,
|
d.collection_updated_at,
|
||||||
@@ -318,18 +325,19 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const loadedImages = await loadImages(references);
|
const loadedImages = await loadImages(references);
|
||||||
|
const isDouyin = task.platform === "抖音";
|
||||||
|
const metricHeaders = isDouyin
|
||||||
|
? ["点赞", "收藏", "转发", "评论", "总互动"]
|
||||||
|
: ["点赞", "收藏", "评论", "总互动"];
|
||||||
const headers = [
|
const headers = [
|
||||||
"序号(不能改)",
|
"序号(不能改)",
|
||||||
"标题",
|
"标题",
|
||||||
"笔记内容(正文+话题)",
|
"笔记内容(正文+话题)",
|
||||||
...Array.from({ length: maxContentImages }, (_, index) => `图片${index + 1}`),
|
...Array.from({ length: maxContentImages }, (_, index) => `图片${index + 1}`),
|
||||||
"小红书昵称",
|
`${task.platform}昵称`,
|
||||||
"发布链接",
|
"发布链接",
|
||||||
"发布时间",
|
"发布时间",
|
||||||
"点赞",
|
...metricHeaders,
|
||||||
"收藏",
|
|
||||||
"评论",
|
|
||||||
"总互动",
|
|
||||||
"曝光量-实际(第7天)",
|
"曝光量-实际(第7天)",
|
||||||
"阅读量-实际(第7天)",
|
"阅读量-实际(第7天)",
|
||||||
"数据分析截图(单篇笔记数据分析截图)",
|
"数据分析截图(单篇笔记数据分析截图)",
|
||||||
@@ -341,7 +349,7 @@ export async function GET(request: Request) {
|
|||||||
];
|
];
|
||||||
const originalImageStart = 3;
|
const originalImageStart = 3;
|
||||||
const accountColumn = originalImageStart + maxContentImages;
|
const accountColumn = originalImageStart + maxContentImages;
|
||||||
const creatorScreenshotColumn = accountColumn + 9;
|
const creatorScreenshotColumn = accountColumn + (isDouyin ? 10 : 9);
|
||||||
const publishScreenshotColumn = creatorScreenshotColumn + 1;
|
const publishScreenshotColumn = creatorScreenshotColumn + 1;
|
||||||
const workbookRows: RecoveryWorkbookRow[] = rows.map((row, rowIndex) => {
|
const workbookRows: RecoveryWorkbookRow[] = rows.map((row, rowIndex) => {
|
||||||
const metrics = latestMetrics(row);
|
const metrics = latestMetrics(row);
|
||||||
@@ -350,7 +358,7 @@ export async function GET(request: Request) {
|
|||||||
{ length: maxContentImages },
|
{ length: maxContentImages },
|
||||||
(_, index) => {
|
(_, index) => {
|
||||||
const asset = contentAssets.find((item) => item.index === index + 1);
|
const asset = contentAssets.find((item) => item.index === index + 1);
|
||||||
return asset && loadedImages.get(asset.key) ? "见图" : asset ? "图片读取失败" : "";
|
return "";
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const creatorImage = row.screenshot_key
|
const creatorImage = row.screenshot_key
|
||||||
@@ -369,12 +377,13 @@ export async function GET(request: Request) {
|
|||||||
formatExportDate(row.publish_time),
|
formatExportDate(row.publish_time),
|
||||||
metrics.likes,
|
metrics.likes,
|
||||||
metrics.collects,
|
metrics.collects,
|
||||||
|
...(isDouyin ? [metrics.shares] : []),
|
||||||
metrics.comments,
|
metrics.comments,
|
||||||
metrics.total,
|
metrics.total,
|
||||||
row.exposure,
|
row.exposure,
|
||||||
row.views,
|
row.views,
|
||||||
creatorImage ? "见图" : row.screenshot_key ? "截图读取失败" : "",
|
"",
|
||||||
publishImage ? "见图" : row.publish_screenshot_key ? "截图读取失败" : "",
|
"",
|
||||||
formatExportDate(row.collection_updated_at || row.updated_at),
|
formatExportDate(row.collection_updated_at || row.updated_at),
|
||||||
row.distribution_id ? collectionLabel(row) : "未领取",
|
row.distribution_id ? collectionLabel(row) : "未领取",
|
||||||
row.partner_name || "",
|
row.partner_name || "",
|
||||||
@@ -403,6 +412,7 @@ export async function GET(request: Request) {
|
|||||||
20,
|
20,
|
||||||
11,
|
11,
|
||||||
11,
|
11,
|
||||||
|
...(isDouyin ? [11] : []),
|
||||||
11,
|
11,
|
||||||
11,
|
11,
|
||||||
18,
|
18,
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ type AccountRow = {
|
|||||||
profile_url: string;
|
profile_url: string;
|
||||||
ip_location: string;
|
ip_location: string;
|
||||||
followers: number;
|
followers: number;
|
||||||
|
gender: string;
|
||||||
|
bio: string;
|
||||||
|
tags: string;
|
||||||
post_count: number;
|
post_count: number;
|
||||||
cooperation_source: string;
|
cooperation_source: string;
|
||||||
tags: string;
|
current_contact: string;
|
||||||
first_seen_at: string;
|
first_seen_at: string;
|
||||||
last_seen_at: string;
|
last_seen_at: string;
|
||||||
};
|
};
|
||||||
@@ -25,6 +28,7 @@ type AccountRow = {
|
|||||||
type CooperationRow = {
|
type CooperationRow = {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
partner_name: string;
|
partner_name: string;
|
||||||
|
claimant_name: string | null;
|
||||||
delegation_bundle_id: string | null;
|
delegation_bundle_id: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,9 +79,11 @@ async function exportAccounts(accountIds: string[]) {
|
|||||||
`SELECT
|
`SELECT
|
||||||
d.account_id,
|
d.account_id,
|
||||||
p.name AS partner_name,
|
p.name AS partner_name,
|
||||||
|
cl.claimant_name,
|
||||||
d.delegation_bundle_id
|
d.delegation_bundle_id
|
||||||
FROM distributions d
|
FROM distributions d
|
||||||
JOIN partners p ON p.id = d.partner_id
|
JOIN partners p ON p.id = d.partner_id
|
||||||
|
LEFT JOIN claims cl ON cl.id = d.claim_id
|
||||||
WHERE d.account_id IS NOT NULL`,
|
WHERE d.account_id IS NOT NULL`,
|
||||||
)
|
)
|
||||||
.all<CooperationRow>(),
|
.all<CooperationRow>(),
|
||||||
@@ -110,9 +116,12 @@ async function exportAccounts(accountIds: string[]) {
|
|||||||
"账号主页",
|
"账号主页",
|
||||||
"IP地",
|
"IP地",
|
||||||
"粉丝数",
|
"粉丝数",
|
||||||
|
"性别",
|
||||||
|
"简介",
|
||||||
|
"标签",
|
||||||
"合作发布数",
|
"合作发布数",
|
||||||
"历史合作来源",
|
"历史合作来源",
|
||||||
"标签",
|
"当前联系人",
|
||||||
"资源归属",
|
"资源归属",
|
||||||
"首次合作时间",
|
"首次合作时间",
|
||||||
"最近合作时间",
|
"最近合作时间",
|
||||||
@@ -121,7 +130,13 @@ async function exportAccounts(accountIds: string[]) {
|
|||||||
const cooperation = cooperationByAccount.get(account.id) ?? [];
|
const cooperation = cooperationByAccount.get(account.id) ?? [];
|
||||||
const sources = [
|
const sources = [
|
||||||
...new Set([
|
...new Set([
|
||||||
...cooperation.map((item) => item.partner_name),
|
...cooperation
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
!item.claimant_name ||
|
||||||
|
item.partner_name !== item.claimant_name,
|
||||||
|
)
|
||||||
|
.map((item) => item.partner_name),
|
||||||
...(account.cooperation_source || "")
|
...(account.cooperation_source || "")
|
||||||
.split(/[、,,;;|]/)
|
.split(/[、,,;;|]/)
|
||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
@@ -140,9 +155,12 @@ async function exportAccounts(accountIds: string[]) {
|
|||||||
account.profile_url || "",
|
account.profile_url || "",
|
||||||
account.ip_location || "待识别",
|
account.ip_location || "待识别",
|
||||||
account.followers,
|
account.followers,
|
||||||
|
account.gender || "",
|
||||||
|
account.bio || "",
|
||||||
|
account.tags || "",
|
||||||
account.post_count,
|
account.post_count,
|
||||||
sources.join("、"),
|
sources.join("、"),
|
||||||
account.tags || "",
|
account.current_contact || "",
|
||||||
partnerManagedOnly ? "合作社资源 · 不可直联" : "可直联",
|
partnerManagedOnly ? "合作社资源 · 不可直联" : "可直联",
|
||||||
formatExportDate(account.first_seen_at),
|
formatExportDate(account.first_seen_at),
|
||||||
formatExportDate(account.last_seen_at),
|
formatExportDate(account.last_seen_at),
|
||||||
@@ -164,9 +182,12 @@ async function exportAccounts(accountIds: string[]) {
|
|||||||
44,
|
44,
|
||||||
14,
|
14,
|
||||||
14,
|
14,
|
||||||
|
10,
|
||||||
|
36,
|
||||||
|
32,
|
||||||
14,
|
14,
|
||||||
32,
|
32,
|
||||||
28,
|
22,
|
||||||
22,
|
22,
|
||||||
21,
|
21,
|
||||||
21,
|
21,
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
|
import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
|
||||||
|
import { runInBackground } from "../../../lib/background";
|
||||||
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
|
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
|
||||||
import {
|
import {
|
||||||
resolveCollectionMcpConfig,
|
resolveCollectionMcpConfig,
|
||||||
resolveXhsProfileDetailsFromMcp,
|
resolveProfileDetailsFromMcp,
|
||||||
resolveXhsPublicAccountDetails,
|
resolveXhsPublicAccountDetails,
|
||||||
type CollectionMcpBindings,
|
type CollectionMcpBindings,
|
||||||
} from "../../../lib/mcp-collection-client";
|
} from "../../../lib/mcp-collection-client";
|
||||||
import { getRuntimeEnv } from "../../../lib/runtime-env";
|
import { getRuntimeEnv } from "../../../lib/runtime-env";
|
||||||
import {
|
import {
|
||||||
mergeCooperationSources,
|
mergeCooperationSources,
|
||||||
mergeTags,
|
|
||||||
normalizeProfileUrl,
|
normalizeProfileUrl,
|
||||||
parseResourceImportFile,
|
parseResourceImportFile,
|
||||||
RESOURCE_IMPORT_MAX_BYTES,
|
RESOURCE_IMPORT_MAX_BYTES,
|
||||||
|
RESOURCE_IMPORT_MAX_ROWS,
|
||||||
resourcePlatformUid,
|
resourcePlatformUid,
|
||||||
resourceImportMissingFields,
|
resourceImportMissingFields,
|
||||||
type ResourceImportRow,
|
type ResourceImportRow,
|
||||||
@@ -27,8 +28,10 @@ type AccountRow = {
|
|||||||
profile_url: string;
|
profile_url: string;
|
||||||
ip_location: string;
|
ip_location: string;
|
||||||
followers: number;
|
followers: number;
|
||||||
cooperation_source: string;
|
gender: string;
|
||||||
|
bio: string;
|
||||||
tags: string;
|
tags: string;
|
||||||
|
cooperation_source: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AnalyzedRow = ResourceImportRow & {
|
type AnalyzedRow = ResourceImportRow & {
|
||||||
@@ -36,9 +39,12 @@ type AnalyzedRow = ResourceImportRow & {
|
|||||||
accountId: string;
|
accountId: string;
|
||||||
platformUid: string;
|
platformUid: string;
|
||||||
cooperationSource: string;
|
cooperationSource: string;
|
||||||
tags: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RESOURCE_IMPORT_PREVIEW_ROWS = 100;
|
||||||
|
const RESOURCE_IMPORT_SYNC_ENRICH_ROWS = 100;
|
||||||
|
const RESOURCE_IMPORT_DB_BATCH_SIZE = 100;
|
||||||
|
|
||||||
function identityKey(platform: string, value: string) {
|
function identityKey(platform: string, value: string) {
|
||||||
return `${platform.trim().toLocaleLowerCase("zh-CN")}|${value
|
return `${platform.trim().toLocaleLowerCase("zh-CN")}|${value
|
||||||
.trim()
|
.trim()
|
||||||
@@ -49,7 +55,8 @@ async function loadAccounts() {
|
|||||||
return getRawDb()
|
return getRawDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, platform, platform_uid, public_account_id, nickname,
|
`SELECT id, platform, platform_uid, public_account_id, nickname,
|
||||||
profile_url, ip_location, followers, cooperation_source, tags
|
profile_url, ip_location, followers, gender, bio, tags,
|
||||||
|
cooperation_source
|
||||||
FROM accounts`,
|
FROM accounts`,
|
||||||
)
|
)
|
||||||
.all<AccountRow>();
|
.all<AccountRow>();
|
||||||
@@ -74,7 +81,7 @@ async function mapConcurrent<T, R>(
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
|
function mergeExistingFields(rows: ResourceImportRow[], accounts: AccountRow[]) {
|
||||||
const existingByProfile = new Map<string, AccountRow>();
|
const existingByProfile = new Map<string, AccountRow>();
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
const profileUrl = normalizeProfileUrl(account.profile_url || "");
|
const profileUrl = normalizeProfileUrl(account.profile_url || "");
|
||||||
@@ -82,21 +89,30 @@ async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
|
|||||||
existingByProfile.set(identityKey(account.platform, profileUrl), account);
|
existingByProfile.set(identityKey(account.platform, profileUrl), account);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const mcpConfig = resolveCollectionMcpConfig(
|
return rows.map((row) => {
|
||||||
getRuntimeEnv() as unknown as CollectionMcpBindings,
|
if (
|
||||||
);
|
row.errors.length > 0 ||
|
||||||
return mapConcurrent(rows, 4, async (row) => {
|
!row.profileUrl ||
|
||||||
if (row.errors.length > 0 || !row.profileUrl || row.platform !== "小红书") {
|
!["小红书", "抖音"].includes(row.platform)
|
||||||
|
) {
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
const existing = existingByProfile.get(identityKey(row.platform, row.profileUrl));
|
const existing = existingByProfile.get(identityKey(row.platform, row.profileUrl));
|
||||||
|
const existingNickname =
|
||||||
|
existing?.nickname && existing.nickname !== "待识别账号"
|
||||||
|
? existing.nickname
|
||||||
|
: "";
|
||||||
const existingIpLocation =
|
const existingIpLocation =
|
||||||
existing?.ip_location && existing.ip_location !== "待识别"
|
existing?.ip_location && existing.ip_location !== "待识别"
|
||||||
? existing.ip_location
|
? existing.ip_location
|
||||||
: "";
|
: "";
|
||||||
const baseline: ResourceImportRow = {
|
const existingGender: ResourceImportRow["gender"] =
|
||||||
|
existing?.gender === "男" || existing?.gender === "女"
|
||||||
|
? existing.gender
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
...row,
|
...row,
|
||||||
nickname: row.nickname || existing?.nickname || "",
|
nickname: row.nickname || existingNickname,
|
||||||
publicAccountId: row.publicAccountId || existing?.public_account_id || "",
|
publicAccountId: row.publicAccountId || existing?.public_account_id || "",
|
||||||
ipLocation: row.ipLocation || existingIpLocation,
|
ipLocation: row.ipLocation || existingIpLocation,
|
||||||
followers: row.followersResolved
|
followers: row.followersResolved
|
||||||
@@ -104,7 +120,33 @@ async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
|
|||||||
: Number(existing?.followers || 0),
|
: Number(existing?.followers || 0),
|
||||||
followersResolved:
|
followersResolved:
|
||||||
row.followersResolved || Number(existing?.followers || 0) > 0,
|
row.followersResolved || Number(existing?.followers || 0) > 0,
|
||||||
|
gender: row.gender || existingGender,
|
||||||
|
bio: row.bio || existing?.bio || "",
|
||||||
|
tags: row.tags.length > 0
|
||||||
|
? row.tags
|
||||||
|
: (existing?.tags || "")
|
||||||
|
.split(/[,,、;;|]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 5),
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
|
||||||
|
const mcpConfig = resolveCollectionMcpConfig(
|
||||||
|
getRuntimeEnv() as unknown as CollectionMcpBindings,
|
||||||
|
);
|
||||||
|
const baselineRows = mergeExistingFields(rows, accounts);
|
||||||
|
return mapConcurrent(baselineRows, 4, async (baseline) => {
|
||||||
|
const row = baseline;
|
||||||
|
if (
|
||||||
|
row.errors.length > 0 ||
|
||||||
|
!row.profileUrl ||
|
||||||
|
!["小红书", "抖音"].includes(row.platform)
|
||||||
|
) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
if (resourceImportMissingFields(baseline).length === 0) {
|
if (resourceImportMissingFields(baseline).length === 0) {
|
||||||
return baseline;
|
return baseline;
|
||||||
}
|
}
|
||||||
@@ -114,20 +156,42 @@ async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
|
|||||||
redId: string | null;
|
redId: string | null;
|
||||||
followers: number | null;
|
followers: number | null;
|
||||||
ipLocation: string | null;
|
ipLocation: string | null;
|
||||||
} = await resolveXhsProfileDetailsFromMcp(row.profileUrl, mcpConfig).catch(
|
gender: "" | "男" | "女";
|
||||||
() => ({ nickname: null, redId: null, followers: null, ipLocation: null }),
|
bio: string;
|
||||||
);
|
recentNoteTitles: string[];
|
||||||
|
providerTags: string[];
|
||||||
|
} = await resolveProfileDetailsFromMcp(
|
||||||
|
row.profileUrl,
|
||||||
|
row.platform === "抖音" ? "抖音" : "小红书",
|
||||||
|
mcpConfig,
|
||||||
|
).catch(() => ({
|
||||||
|
nickname: null,
|
||||||
|
redId: null,
|
||||||
|
followers: null,
|
||||||
|
ipLocation: null,
|
||||||
|
gender: "" as const,
|
||||||
|
bio: "",
|
||||||
|
recentNoteTitles: [],
|
||||||
|
providerTags: [],
|
||||||
|
}));
|
||||||
const mcpResult = {
|
const mcpResult = {
|
||||||
nickname: baseline.nickname || details.nickname?.trim() || "",
|
nickname: baseline.nickname || details.nickname?.trim() || "",
|
||||||
publicAccountId: baseline.publicAccountId || details.redId?.trim() || "",
|
publicAccountId: baseline.publicAccountId || details.redId?.trim() || "",
|
||||||
ipLocation: baseline.ipLocation || details.ipLocation?.trim() || "",
|
ipLocation: baseline.ipLocation || details.ipLocation?.trim() || "",
|
||||||
followersResolved: baseline.followersResolved || details.followers !== null,
|
followersResolved: baseline.followersResolved || details.followers !== null,
|
||||||
|
gender: baseline.gender || details.gender,
|
||||||
|
bio: baseline.bio || details.bio,
|
||||||
|
tags: baseline.tags,
|
||||||
};
|
};
|
||||||
if (resourceImportMissingFields(mcpResult).length > 0) {
|
if (
|
||||||
|
row.platform === "小红书" &&
|
||||||
|
resourceImportMissingFields(mcpResult).length > 0
|
||||||
|
) {
|
||||||
const publicDetails = await resolveXhsPublicAccountDetails(row.profileUrl).catch(
|
const publicDetails = await resolveXhsPublicAccountDetails(row.profileUrl).catch(
|
||||||
() => ({ nickname: null, redId: null, followers: null, ipLocation: null }),
|
() => ({ nickname: null, redId: null, followers: null, ipLocation: null }),
|
||||||
);
|
);
|
||||||
details = {
|
details = {
|
||||||
|
...details,
|
||||||
nickname: details.nickname || publicDetails.nickname,
|
nickname: details.nickname || publicDetails.nickname,
|
||||||
redId: details.redId || publicDetails.redId,
|
redId: details.redId || publicDetails.redId,
|
||||||
followers: details.followers ?? publicDetails.followers,
|
followers: details.followers ?? publicDetails.followers,
|
||||||
@@ -144,6 +208,9 @@ async function enrichRows(rows: ResourceImportRow[], accounts: AccountRow[]) {
|
|||||||
: (details.followers ?? 0),
|
: (details.followers ?? 0),
|
||||||
followersResolved:
|
followersResolved:
|
||||||
baseline.followersResolved || details.followers !== null,
|
baseline.followersResolved || details.followers !== null,
|
||||||
|
gender: baseline.gender || details.gender,
|
||||||
|
bio: baseline.bio || details.bio,
|
||||||
|
tags: baseline.tags,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -190,7 +257,6 @@ function analyzeRows(rows: ResourceImportRow[], accountRows: AccountRow[]) {
|
|||||||
existing?.cooperation_source ?? "",
|
existing?.cooperation_source ?? "",
|
||||||
row.cooperationSource,
|
row.cooperationSource,
|
||||||
),
|
),
|
||||||
tags: mergeTags(existing?.tags ?? "", row.tags),
|
|
||||||
};
|
};
|
||||||
if (analyzed.action !== "error") {
|
if (analyzed.action !== "error") {
|
||||||
const virtual: AccountRow = {
|
const virtual: AccountRow = {
|
||||||
@@ -202,8 +268,13 @@ function analyzeRows(rows: ResourceImportRow[], accountRows: AccountRow[]) {
|
|||||||
profile_url: row.profileUrl || existing?.profile_url || "",
|
profile_url: row.profileUrl || existing?.profile_url || "",
|
||||||
ip_location: row.ipLocation || existing?.ip_location || "待识别",
|
ip_location: row.ipLocation || existing?.ip_location || "待识别",
|
||||||
followers: row.followers || existing?.followers || 0,
|
followers: row.followers || existing?.followers || 0,
|
||||||
|
gender: row.gender || existing?.gender || "",
|
||||||
|
bio: row.bio || existing?.bio || "",
|
||||||
|
tags: (row.tags.length > 0
|
||||||
|
? row.tags
|
||||||
|
: (existing?.tags || "").split(/[,,、;;|]/).filter(Boolean)
|
||||||
|
).slice(0, 5).join(","),
|
||||||
cooperation_source: analyzed.cooperationSource,
|
cooperation_source: analyzed.cooperationSource,
|
||||||
tags: analyzed.tags,
|
|
||||||
};
|
};
|
||||||
if (row.profileUrl) profileMap.set(identityKey(row.platform, row.profileUrl), virtual);
|
if (row.profileUrl) profileMap.set(identityKey(row.platform, row.profileUrl), virtual);
|
||||||
if (row.publicAccountId) {
|
if (row.publicAccountId) {
|
||||||
@@ -224,6 +295,123 @@ function summarize(rows: AnalyzedRow[]) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function previewAnalyzedRows(rows: AnalyzedRow[]) {
|
||||||
|
const errorRows = rows.filter((row) => row.action === "error");
|
||||||
|
if (errorRows.length === 0) {
|
||||||
|
return rows.slice(0, RESOURCE_IMPORT_PREVIEW_ROWS);
|
||||||
|
}
|
||||||
|
const importableRows = rows.filter((row) => row.action !== "error");
|
||||||
|
return [
|
||||||
|
...errorRows.slice(0, RESOURCE_IMPORT_PREVIEW_ROWS),
|
||||||
|
...importableRows.slice(
|
||||||
|
0,
|
||||||
|
Math.max(0, RESOURCE_IMPORT_PREVIEW_ROWS - errorRows.length),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function statementForAnalyzedRow(
|
||||||
|
db: ReturnType<typeof getRawDb>,
|
||||||
|
row: AnalyzedRow,
|
||||||
|
) {
|
||||||
|
return row.action === "update"
|
||||||
|
? db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE accounts SET
|
||||||
|
nickname = ?,
|
||||||
|
public_account_id = CASE WHEN ? != '' THEN ? ELSE public_account_id END,
|
||||||
|
profile_url = CASE WHEN ? != '' THEN ? ELSE profile_url END,
|
||||||
|
ip_location = CASE
|
||||||
|
WHEN ? != '' AND ? != '待识别' THEN ? ELSE ip_location END,
|
||||||
|
followers = CASE WHEN ? = 1 THEN ? ELSE followers END,
|
||||||
|
gender = CASE WHEN ? != '' THEN ? ELSE gender END,
|
||||||
|
bio = CASE WHEN ? != '' THEN ? ELSE bio END,
|
||||||
|
tags = CASE WHEN ? != '' THEN ? ELSE tags END,
|
||||||
|
cooperation_source = ?,
|
||||||
|
last_seen_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
row.nickname || "待识别账号",
|
||||||
|
row.publicAccountId,
|
||||||
|
row.publicAccountId,
|
||||||
|
row.profileUrl,
|
||||||
|
row.profileUrl,
|
||||||
|
row.ipLocation,
|
||||||
|
row.ipLocation,
|
||||||
|
row.ipLocation,
|
||||||
|
row.followersResolved ? 1 : 0,
|
||||||
|
row.followers,
|
||||||
|
row.gender,
|
||||||
|
row.gender,
|
||||||
|
row.bio,
|
||||||
|
row.bio,
|
||||||
|
row.tags.join(","),
|
||||||
|
row.tags.join(","),
|
||||||
|
row.cooperationSource,
|
||||||
|
row.accountId,
|
||||||
|
)
|
||||||
|
: db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO accounts
|
||||||
|
(id, platform, platform_uid, public_account_id, nickname,
|
||||||
|
profile_url, ip_location, followers, post_count, avg_views,
|
||||||
|
gender, bio, tags, cooperation_source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
row.accountId,
|
||||||
|
row.platform,
|
||||||
|
row.platformUid,
|
||||||
|
row.publicAccountId,
|
||||||
|
row.nickname || "待识别账号",
|
||||||
|
row.profileUrl,
|
||||||
|
row.ipLocation || "待识别",
|
||||||
|
row.followers,
|
||||||
|
row.gender,
|
||||||
|
row.bio,
|
||||||
|
row.tags.join(","),
|
||||||
|
row.cooperationSource,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeAnalyzedRows(rows: AnalyzedRow[]) {
|
||||||
|
const db = getRawDb();
|
||||||
|
const statements = rows
|
||||||
|
.filter((row) => row.action !== "error")
|
||||||
|
.map((row) => statementForAnalyzedRow(db, row));
|
||||||
|
for (let index = 0; index < statements.length; index += RESOURCE_IMPORT_DB_BATCH_SIZE) {
|
||||||
|
await db.batch(statements.slice(index, index + RESOURCE_IMPORT_DB_BATCH_SIZE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferredEnrichmentCount(rows: ResourceImportRow[]) {
|
||||||
|
return rows.filter(
|
||||||
|
(row) =>
|
||||||
|
row.errors.length === 0 &&
|
||||||
|
row.profileUrl &&
|
||||||
|
resourceImportMissingFields(row).length > 0,
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichImportedRowsInBackground(rows: ResourceImportRow[]) {
|
||||||
|
const accounts = await loadAccounts();
|
||||||
|
const baselineRows = mergeExistingFields(rows, accounts.results);
|
||||||
|
const missingRows = baselineRows.filter(
|
||||||
|
(row) =>
|
||||||
|
row.errors.length === 0 &&
|
||||||
|
row.profileUrl &&
|
||||||
|
resourceImportMissingFields(row).length > 0,
|
||||||
|
);
|
||||||
|
if (missingRows.length === 0) return;
|
||||||
|
const enriched = await enrichRows(missingRows, accounts.results);
|
||||||
|
const latestAccounts = await loadAccounts();
|
||||||
|
const analyzed = analyzeRows(enriched, latestAccounts.results).filter(
|
||||||
|
(row) => row.action !== "error",
|
||||||
|
);
|
||||||
|
await writeAnalyzedRows(analyzed);
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
if (!(await isManagerRequest(request))) return managerForbidden();
|
if (!(await isManagerRequest(request))) return managerForbidden();
|
||||||
try {
|
try {
|
||||||
@@ -235,17 +423,30 @@ export async function POST(request: Request) {
|
|||||||
return Response.json({ error: "请选择需要导入的 Excel 文件" }, { status: 400 });
|
return Response.json({ error: "请选择需要导入的 Excel 文件" }, { status: 400 });
|
||||||
}
|
}
|
||||||
if (file.size <= 0 || file.size > RESOURCE_IMPORT_MAX_BYTES) {
|
if (file.size <= 0 || file.size > RESOURCE_IMPORT_MAX_BYTES) {
|
||||||
return Response.json({ error: "文件不能为空,且不能超过 5MB" }, { status: 400 });
|
return Response.json({ error: "文件不能为空,且不能超过 20MB" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const rows = parseResourceImportFile(file.name, new Uint8Array(await file.arrayBuffer()));
|
const rows = parseResourceImportFile(file.name, new Uint8Array(await file.arrayBuffer()));
|
||||||
const accounts = await loadAccounts();
|
const accounts = await loadAccounts();
|
||||||
const enriched = await enrichRows(rows, accounts.results);
|
const shouldEnrichSynchronously = rows.length <= RESOURCE_IMPORT_SYNC_ENRICH_ROWS;
|
||||||
const analyzed = analyzeRows(enriched, accounts.results);
|
const preparedRows = shouldEnrichSynchronously
|
||||||
|
? await enrichRows(rows, accounts.results)
|
||||||
|
: mergeExistingFields(rows, accounts.results);
|
||||||
|
const analyzed = analyzeRows(preparedRows, accounts.results);
|
||||||
const summary = summarize(analyzed);
|
const summary = summarize(analyzed);
|
||||||
|
const importableRows = analyzed.filter((row) => row.action !== "error");
|
||||||
|
const importableRowNumbers = new Set(
|
||||||
|
importableRows.map((row) => row.rowNumber),
|
||||||
|
);
|
||||||
|
const importablePreparedRows = preparedRows.filter((row) =>
|
||||||
|
importableRowNumbers.has(row.rowNumber),
|
||||||
|
);
|
||||||
|
const deferredEnrichment = shouldEnrichSynchronously
|
||||||
|
? 0
|
||||||
|
: deferredEnrichmentCount(importablePreparedRows);
|
||||||
if (mode !== "commit") {
|
if (mode !== "commit") {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
summary,
|
summary,
|
||||||
rows: analyzed.slice(0, 100).map((row) => ({
|
rows: previewAnalyzedRows(analyzed).map((row) => ({
|
||||||
rowNumber: row.rowNumber,
|
rowNumber: row.rowNumber,
|
||||||
platform: row.platform,
|
platform: row.platform,
|
||||||
nickname: row.nickname,
|
nickname: row.nickname,
|
||||||
@@ -253,78 +454,44 @@ export async function POST(request: Request) {
|
|||||||
profileUrl: row.profileUrl,
|
profileUrl: row.profileUrl,
|
||||||
ipLocation: row.ipLocation,
|
ipLocation: row.ipLocation,
|
||||||
followers: row.followers,
|
followers: row.followers,
|
||||||
cooperationSource: row.cooperationSource,
|
gender: row.gender,
|
||||||
|
bio: row.bio,
|
||||||
tags: row.tags,
|
tags: row.tags,
|
||||||
|
cooperationSource: row.cooperationSource,
|
||||||
action: row.action,
|
action: row.action,
|
||||||
errors: row.errors,
|
errors: row.errors,
|
||||||
})),
|
})),
|
||||||
truncated: analyzed.length > 100,
|
truncated: analyzed.length > RESOURCE_IMPORT_PREVIEW_ROWS,
|
||||||
|
deferredEnrichment,
|
||||||
|
maxRows: RESOURCE_IMPORT_MAX_ROWS,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (summary.error > 0) {
|
if (importableRows.length === 0) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: `有 ${summary.error} 行数据未通过校验,请修正后重新上传`, summary },
|
{ error: "没有可导入的有效账号,请修正异常数据后重新上传", summary },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await writeAnalyzedRows(importableRows);
|
||||||
const db = getRawDb();
|
if (deferredEnrichment > 0) {
|
||||||
const statements = analyzed.map((row) =>
|
const importableSourceRows = rows.filter((row) =>
|
||||||
row.action === "update"
|
importableRowNumbers.has(row.rowNumber),
|
||||||
? db
|
);
|
||||||
.prepare(
|
runInBackground(
|
||||||
`UPDATE accounts SET
|
enrichImportedRowsInBackground(importableSourceRows),
|
||||||
nickname = ?,
|
"bulk resource profile enrichment",
|
||||||
public_account_id = CASE WHEN ? != '' THEN ? ELSE public_account_id END,
|
);
|
||||||
profile_url = CASE WHEN ? != '' THEN ? ELSE profile_url END,
|
}
|
||||||
ip_location = CASE
|
|
||||||
WHEN ? != '' AND ? != '待识别' THEN ? ELSE ip_location END,
|
|
||||||
followers = CASE WHEN ? = 1 THEN ? ELSE followers END,
|
|
||||||
cooperation_source = ?,
|
|
||||||
tags = ?,
|
|
||||||
last_seen_at = CURRENT_TIMESTAMP
|
|
||||||
WHERE id = ?`,
|
|
||||||
)
|
|
||||||
.bind(
|
|
||||||
row.nickname,
|
|
||||||
row.publicAccountId,
|
|
||||||
row.publicAccountId,
|
|
||||||
row.profileUrl,
|
|
||||||
row.profileUrl,
|
|
||||||
row.ipLocation,
|
|
||||||
row.ipLocation,
|
|
||||||
row.ipLocation,
|
|
||||||
row.followersResolved ? 1 : 0,
|
|
||||||
row.followers,
|
|
||||||
row.cooperationSource,
|
|
||||||
row.tags,
|
|
||||||
row.accountId,
|
|
||||||
)
|
|
||||||
: db
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO accounts
|
|
||||||
(id, platform, platform_uid, public_account_id, nickname,
|
|
||||||
profile_url, ip_location, followers, post_count, avg_views,
|
|
||||||
cooperation_source, tags)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`,
|
|
||||||
)
|
|
||||||
.bind(
|
|
||||||
row.accountId,
|
|
||||||
row.platform,
|
|
||||||
row.platformUid,
|
|
||||||
row.publicAccountId,
|
|
||||||
row.nickname,
|
|
||||||
row.profileUrl,
|
|
||||||
row.ipLocation,
|
|
||||||
row.followers,
|
|
||||||
row.cooperationSource,
|
|
||||||
row.tags,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (statements.length > 0) await db.batch(statements);
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
summary,
|
summary,
|
||||||
message: `已导入 ${summary.create} 个新账号,更新 ${summary.update} 个已有账号`,
|
deferredEnrichment,
|
||||||
|
message: `已导入 ${summary.create} 个新账号,更新 ${summary.update} 个已有账号${
|
||||||
|
summary.error > 0 ? `;跳过 ${summary.error} 条异常数据` : ""
|
||||||
|
}${
|
||||||
|
deferredEnrichment > 0
|
||||||
|
? `;${deferredEnrichment} 个账号的缺失公开资料将在后台补全`
|
||||||
|
: ""
|
||||||
|
}`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "导入失败";
|
const message = error instanceof Error ? error.message : "导入失败";
|
||||||
|
|||||||
767
app/globals.css
@@ -235,6 +235,109 @@ button:disabled {
|
|||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.platform-badge {
|
||||||
|
display: inline-flex !important;
|
||||||
|
width: auto !important;
|
||||||
|
min-width: 0;
|
||||||
|
height: 24px;
|
||||||
|
align-items: center;
|
||||||
|
flex: none;
|
||||||
|
gap: 5px;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 2px 7px 2px 3px;
|
||||||
|
border: 1px solid #e3e8e5;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #52615c !important;
|
||||||
|
background: #f7f9f7;
|
||||||
|
font-size: 9px !important;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1 !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge.compact {
|
||||||
|
height: 19px;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 5px 2px 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-logo {
|
||||||
|
display: grid !important;
|
||||||
|
width: 18px !important;
|
||||||
|
height: 18px !important;
|
||||||
|
place-items: center;
|
||||||
|
flex: none;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0 !important;
|
||||||
|
border-radius: 5px;
|
||||||
|
line-height: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge.compact .platform-logo {
|
||||||
|
width: 14px !important;
|
||||||
|
height: 14px !important;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-logo.xiaohongshu {
|
||||||
|
color: white !important;
|
||||||
|
background: #ff2442;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-logo.xiaohongshu b {
|
||||||
|
color: inherit;
|
||||||
|
font-size: 5px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.12em;
|
||||||
|
transform: translateX(-0.2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge.compact .platform-logo.xiaohongshu b {
|
||||||
|
font-size: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-logo.douyin {
|
||||||
|
background: #080b12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-logo.douyin svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge.compact .platform-logo.douyin svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-logo.douyin .douyin-cyan { fill: #25f4ee; transform: translate(-0.7px, 0.7px); }
|
||||||
|
.platform-logo.douyin .douyin-red { fill: #fe2c55; transform: translate(0.7px, -0.3px); }
|
||||||
|
.platform-logo.douyin .douyin-white { fill: #fff; }
|
||||||
|
|
||||||
|
.platform-logo.other {
|
||||||
|
color: white !important;
|
||||||
|
background: #7b8783;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-logo.other b {
|
||||||
|
color: inherit;
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-meta-line {
|
||||||
|
display: inline-flex !important;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-meta-line > span {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -1508,13 +1611,270 @@ a {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-scope-head > div:nth-child(2) > span {
|
.task-scope-subline {
|
||||||
display: block;
|
display: flex;
|
||||||
margin-top: 4px;
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-scope-subline > span:first-child {
|
||||||
color: #929c98;
|
color: #929c98;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-format-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
height: 19px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 7px;
|
||||||
|
border: 1px solid #dce8e3;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #477064;
|
||||||
|
background: #f0f7f4;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-format-badge.video {
|
||||||
|
border-color: #ddd6ed;
|
||||||
|
color: #6a568c;
|
||||||
|
background: #f5f1fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-format-badge.screenshot {
|
||||||
|
border-color: #d5e6eb;
|
||||||
|
color: #47727d;
|
||||||
|
background: #eef7f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 13px;
|
||||||
|
border: 1px solid #e5ebe8;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8faf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-search {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
min-width: 240px;
|
||||||
|
height: 38px;
|
||||||
|
flex: 1 1 320px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #dfe6e2;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-search:focus-within {
|
||||||
|
border-color: #74b79f;
|
||||||
|
box-shadow: 0 0 0 3px rgb(31 143 107 / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-search > span {
|
||||||
|
display: grid;
|
||||||
|
width: 18px;
|
||||||
|
flex: 0 0 18px;
|
||||||
|
place-items: center;
|
||||||
|
color: #83918c;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-search input {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-filter-group {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-filter-combobox {
|
||||||
|
position: relative;
|
||||||
|
width: 150px;
|
||||||
|
flex: 0 1 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-filter-combobox.brand {
|
||||||
|
width: 176px;
|
||||||
|
flex-basis: 176px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-filter-input {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
flex: none;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 11px;
|
||||||
|
border: 1px solid #dfe6e2;
|
||||||
|
border-radius: 9px;
|
||||||
|
outline: 0;
|
||||||
|
color: #28332f;
|
||||||
|
background: white;
|
||||||
|
font-size: 9px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-filter-input > span {
|
||||||
|
display: grid;
|
||||||
|
width: 16px;
|
||||||
|
flex: 0 0 16px;
|
||||||
|
place-items: center;
|
||||||
|
color: #83918c;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-filter-input input {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
color: #28332f;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-filter-input:hover,
|
||||||
|
.distribution-task-filter-input:focus-within {
|
||||||
|
border-color: #74b79f;
|
||||||
|
box-shadow: 0 0 0 3px rgb(31 143 107 / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-filter-menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 60;
|
||||||
|
top: calc(100% + 7px);
|
||||||
|
left: 0;
|
||||||
|
display: grid;
|
||||||
|
width: max(100%, 230px);
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #dce8e3;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 16px 36px rgb(20 45 37 / 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-filter-menu-options {
|
||||||
|
display: grid;
|
||||||
|
max-height: 240px;
|
||||||
|
gap: 3px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-filter-menu-options button {
|
||||||
|
display: flex;
|
||||||
|
min-height: 34px;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #34423d;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 9px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-filter-menu-options button:hover,
|
||||||
|
.distribution-task-filter-menu-options button:focus-visible {
|
||||||
|
outline: 0;
|
||||||
|
background: #f3f9f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-filter-menu-options button.selected {
|
||||||
|
color: var(--green-deep);
|
||||||
|
background: #eaf6f1;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-filter-menu-options button small {
|
||||||
|
color: #6f9d8d;
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-filter-empty {
|
||||||
|
padding: 12px 10px;
|
||||||
|
color: #98a39f;
|
||||||
|
font-size: 9px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-clear {
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: 0;
|
||||||
|
color: var(--green-deep);
|
||||||
|
background: transparent;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-count {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #8c9894;
|
||||||
|
font-size: 9px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-count b {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-empty {
|
||||||
|
display: grid;
|
||||||
|
min-height: 190px;
|
||||||
|
place-content: center;
|
||||||
|
gap: 7px;
|
||||||
|
border: 1px dashed #dce5e1;
|
||||||
|
border-radius: 15px;
|
||||||
|
color: #8d9894;
|
||||||
|
background: #fbfcfb;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-empty strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-empty span {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
.task-source-line {
|
.task-source-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2008,18 +2368,6 @@ a {
|
|||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-preview-row em {
|
|
||||||
display: block;
|
|
||||||
margin-top: 3px;
|
|
||||||
color: #4a5575;
|
|
||||||
background: #eef1f6;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
font-size: 8px;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 580;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-result {
|
.import-result {
|
||||||
color: #557269;
|
color: #557269;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
@@ -2036,6 +2384,17 @@ a {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.import-preview-warning {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #f0d5cc;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #a95037;
|
||||||
|
background: #fff7f3;
|
||||||
|
font-size: 9px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.form-error {
|
.form-error {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -2049,136 +2408,299 @@ a {
|
|||||||
.resource-grid {
|
.resource-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-card {
|
.resource-card {
|
||||||
padding: 18px;
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 16px;
|
||||||
border: 1px solid #e5e9e6;
|
border: 1px solid #e5e9e6;
|
||||||
border-radius: 14px;
|
border-top-width: 3px;
|
||||||
|
border-radius: 16px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
transition: 150ms ease;
|
box-shadow: 0 8px 24px rgb(23 56 45 / 0.035);
|
||||||
|
transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-card.platform-xhs {
|
||||||
|
border-top-color: #ff5470;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-card.platform-douyin {
|
||||||
|
border-top-color: #25383a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-card.platform-other {
|
||||||
|
border-top-color: #8b9692;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-card:hover {
|
.resource-card:hover {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-2px);
|
||||||
box-shadow: var(--shadow);
|
border-color: #d6e0dc;
|
||||||
|
box-shadow: 0 14px 34px rgb(23 56 45 / 0.09);
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-card-head {
|
.resource-card-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
min-width: 0;
|
||||||
gap: 11px;
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-card-head > div:nth-child(2) {
|
.resource-card-head .resource-profile-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resource-profile-avatar.avatar.xlarge {
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
border: 3px solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 1px #e6ebe8;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-card-head h3 {
|
.resource-card-head h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 12px;
|
color: #1f2c28;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 720;
|
||||||
|
line-height: 1.35;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-card-head span {
|
.resource-name-line {
|
||||||
margin-top: 4px;
|
|
||||||
color: #8c9894;
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.verified-dot {
|
|
||||||
display: grid;
|
|
||||||
width: 19px;
|
|
||||||
height: 19px;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
color: #fff !important;
|
|
||||||
background: var(--green);
|
|
||||||
font-size: 9px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-account-id {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 6px;
|
||||||
margin-top: 14px;
|
|
||||||
padding: 9px 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #77847f;
|
|
||||||
background: #f5f7f5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-account-id span {
|
.resource-name-line h3 {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-icon {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
font-size: 8px;
|
place-items: center;
|
||||||
|
margin: 0 !important;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 11px !important;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-account-id strong {
|
.gender-icon.male {
|
||||||
|
color: #347a9c !important;
|
||||||
|
background: #e8f4fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-icon.female {
|
||||||
|
color: #b95778 !important;
|
||||||
|
background: #fbeaf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-account-number {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-account-number span {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #9aa4a0;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-account-number strong {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #40534d;
|
color: #63706c;
|
||||||
font-family: var(--font-geist-mono), monospace;
|
font-family: var(--font-geist-mono), monospace;
|
||||||
font-size: 9px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
user-select: all;
|
user-select: all;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.resource-platform-line {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-current-contact {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 6px;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-current-contact strong {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-location {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #7e8a86;
|
||||||
|
font-size: 9px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-bio {
|
||||||
|
display: -webkit-box;
|
||||||
|
min-height: 43px;
|
||||||
|
margin: 13px 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #58635f;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.55;
|
||||||
|
word-break: break-word;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-bio.empty {
|
||||||
|
min-height: auto;
|
||||||
|
color: #adb5b2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-tags {
|
||||||
|
min-height: 23px;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-tags > div {
|
||||||
|
display: flex;
|
||||||
|
max-height: 50px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-tags b {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid #dcebe5;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: #32765f;
|
||||||
|
background: #f4faf7;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-card.platform-xhs .resource-tags b {
|
||||||
|
border-color: #f2dce2;
|
||||||
|
color: #9b4e63;
|
||||||
|
background: #fff2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-tags.empty b {
|
||||||
|
border-color: #e7ebe9;
|
||||||
|
color: #a0aaa6;
|
||||||
|
background: #f7f8f7;
|
||||||
|
font-weight: 560;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-metrics {
|
.resource-metrics {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
min-width: 0;
|
||||||
|
align-items: stretch;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
padding: 13px 0;
|
padding: 9px 10px;
|
||||||
border-block: 1px solid #eef1ef;
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f6f8f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-metrics > div {
|
.resource-metrics > div {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
min-width: 0;
|
||||||
border-right: 1px solid #eef1ef;
|
flex: 0 0 auto;
|
||||||
text-align: center;
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
padding-inline: 9px;
|
||||||
|
border-right: 1px solid #e1e7e4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-metrics > div:first-child {
|
||||||
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-metrics > div:last-child {
|
.resource-metrics > div:last-child {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-right: 0;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-metrics span,
|
.resource-metrics span {
|
||||||
.resource-source > span {
|
color: #98a39f;
|
||||||
color: #9ba4a1;
|
font-size: 9px;
|
||||||
font-size: 8px;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-metrics strong {
|
.resource-metrics strong {
|
||||||
margin-top: 4px;
|
overflow: hidden;
|
||||||
font-size: 13px;
|
color: #24332e;
|
||||||
|
font-size: 11px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-source {
|
.resource-source {
|
||||||
margin-top: 13px;
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-source > span {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #9ba4a1;
|
||||||
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-source > div {
|
.resource-source > div {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
margin-top: 8px;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-source b {
|
.resource-source b {
|
||||||
padding: 5px 7px;
|
flex: 0 0 auto;
|
||||||
|
padding: 4px 6px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #567068;
|
color: #567068;
|
||||||
background: #eef4f1;
|
background: #eef4f1;
|
||||||
font-size: 8px;
|
font-size: 9px;
|
||||||
font-weight: 580;
|
font-weight: 580;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2187,42 +2709,31 @@ a {
|
|||||||
background: #fff3e8;
|
background: #fff3e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-tags {
|
.resource-source b.empty {
|
||||||
margin-top: 13px;
|
color: #9fa8a5;
|
||||||
}
|
background: #f4f6f5;
|
||||||
|
|
||||||
.resource-tags > span {
|
|
||||||
color: #9ba4a1;
|
|
||||||
font-size: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-tags > div {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 5px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-tags b {
|
|
||||||
padding: 5px 7px;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #4a5575;
|
|
||||||
background: #eef1f6;
|
|
||||||
font-size: 8px;
|
|
||||||
font-weight: 580;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-card-foot {
|
.resource-card-foot {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: 14px;
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #eef1ef;
|
||||||
color: #9aa39f;
|
color: #9aa39f;
|
||||||
font-size: 8px;
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-card-foot .resource-source {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-card-foot a {
|
.resource-card-foot a {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resource-empty {
|
.resource-empty {
|
||||||
@@ -2672,6 +3183,34 @@ a {
|
|||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.recovery-row-actions {
|
||||||
|
display: flex;
|
||||||
|
min-width: 82px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-url-edit-button {
|
||||||
|
padding: 2px 0;
|
||||||
|
border: 0;
|
||||||
|
color: #168565;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-url-edit-button:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publish-url-edit-button:disabled {
|
||||||
|
color: #aab4b0;
|
||||||
|
cursor: wait;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-button {
|
.upload-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
@@ -3866,6 +4405,11 @@ label small {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.distribution-task-count {
|
||||||
|
width: 100%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.workflow-track {
|
.workflow-track {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
@@ -4051,6 +4595,35 @@ label small {
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.distribution-task-toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-search,
|
||||||
|
.distribution-task-filter-group,
|
||||||
|
.distribution-task-filter-combobox,
|
||||||
|
.distribution-task-filter-combobox.brand {
|
||||||
|
width: 100%;
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-filter-group {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-clear {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-task-count {
|
||||||
|
display: flex;
|
||||||
|
width: auto;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-search,
|
.resource-search,
|
||||||
.resource-filter-search {
|
.resource-filter-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
15
db/schema.ts
@@ -19,7 +19,6 @@ export const partners = mysqlTable("partners", {
|
|||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
wecomName: varchar("wecom_name", { length: 255 }).notNull(),
|
wecomName: varchar("wecom_name", { length: 255 }).notNull(),
|
||||||
owner: varchar("owner", { length: 255 }).notNull().default("运营组"),
|
owner: varchar("owner", { length: 255 }).notNull().default("运营组"),
|
||||||
wecomExternalUserId: varchar("wecom_external_user_id", { length: 128 }),
|
|
||||||
claimedTotal: int("claimed_total").notNull().default(0),
|
claimedTotal: int("claimed_total").notNull().default(0),
|
||||||
completedTotal: int("completed_total").notNull().default(0),
|
completedTotal: int("completed_total").notNull().default(0),
|
||||||
createdAt: timestamp("created_at"),
|
createdAt: timestamp("created_at"),
|
||||||
@@ -38,6 +37,10 @@ export const tasks = mysqlTable(
|
|||||||
taskType: varchar("task_type", { length: 32 })
|
taskType: varchar("task_type", { length: 32 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("content_publish"),
|
.default("content_publish"),
|
||||||
|
platform: varchar("platform", { length: 32 }).notNull().default("小红书"),
|
||||||
|
contentFormat: varchar("content_format", { length: 32 })
|
||||||
|
.notNull()
|
||||||
|
.default("image_text"),
|
||||||
sourceUrl: text("source_url").notNull(),
|
sourceUrl: text("source_url").notNull(),
|
||||||
sourceSheetId: varchar("source_sheet_id", { length: 255 }).notNull().default(""),
|
sourceSheetId: varchar("source_sheet_id", { length: 255 }).notNull().default(""),
|
||||||
sourceSheetName: varchar("source_sheet_name", { length: 255 }).notNull().default(""),
|
sourceSheetName: varchar("source_sheet_name", { length: 255 }).notNull().default(""),
|
||||||
@@ -60,6 +63,7 @@ export const contents = mysqlTable("contents", {
|
|||||||
title: text("title").notNull(),
|
title: text("title").notNull(),
|
||||||
body: text("body").notNull(),
|
body: text("body").notNull(),
|
||||||
imageAssets: text("image_assets").notNull(),
|
imageAssets: text("image_assets").notNull(),
|
||||||
|
videoAssets: text("video_assets").notNull(),
|
||||||
status: varchar("status", { length: 32 }).notNull().default("available"),
|
status: varchar("status", { length: 32 }).notNull().default("available"),
|
||||||
source: varchar("source", { length: 255 }).notNull().default("飞书内容表"),
|
source: varchar("source", { length: 255 }).notNull().default("飞书内容表"),
|
||||||
sourceRow: int("source_row"),
|
sourceRow: int("source_row"),
|
||||||
@@ -77,12 +81,17 @@ export const accounts = mysqlTable(
|
|||||||
profileUrl: text("profile_url").notNull(),
|
profileUrl: text("profile_url").notNull(),
|
||||||
ipLocation: varchar("ip_location", { length: 255 }).notNull().default("待识别"),
|
ipLocation: varchar("ip_location", { length: 255 }).notNull().default("待识别"),
|
||||||
followers: int("followers").notNull().default(0),
|
followers: int("followers").notNull().default(0),
|
||||||
|
gender: varchar("gender", { length: 16 }).notNull().default(""),
|
||||||
|
bio: text("bio").notNull().default(""),
|
||||||
|
tags: varchar("tags", { length: 500 }).notNull().default(""),
|
||||||
postCount: int("post_count").notNull().default(0),
|
postCount: int("post_count").notNull().default(0),
|
||||||
avgViews: int("avg_views").notNull().default(0),
|
avgViews: int("avg_views").notNull().default(0),
|
||||||
cooperationSource: varchar("cooperation_source", { length: 500 })
|
cooperationSource: varchar("cooperation_source", { length: 500 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(""),
|
.default(""),
|
||||||
tags: varchar("tags", { length: 500 }).notNull().default(""),
|
currentContact: varchar("current_contact", { length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.default(""),
|
||||||
firstSeenAt: timestamp("first_seen_at"),
|
firstSeenAt: timestamp("first_seen_at"),
|
||||||
lastSeenAt: timestamp("last_seen_at"),
|
lastSeenAt: timestamp("last_seen_at"),
|
||||||
},
|
},
|
||||||
@@ -164,6 +173,7 @@ export const distributions = mysqlTable("distributions", {
|
|||||||
latestLikes: int("latest_likes"),
|
latestLikes: int("latest_likes"),
|
||||||
latestComments: int("latest_comments"),
|
latestComments: int("latest_comments"),
|
||||||
latestCollects: int("latest_collects"),
|
latestCollects: int("latest_collects"),
|
||||||
|
latestShares: int("latest_shares"),
|
||||||
collectionStatus: varchar("collection_status", { length: 32 })
|
collectionStatus: varchar("collection_status", { length: 32 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("pending"),
|
.default("pending"),
|
||||||
@@ -186,6 +196,7 @@ export const collectionRuns = mysqlTable(
|
|||||||
likes: int("likes"),
|
likes: int("likes"),
|
||||||
comments: int("comments"),
|
comments: int("comments"),
|
||||||
collects: int("collects"),
|
collects: int("collects"),
|
||||||
|
shares: int("shares"),
|
||||||
statusDescription: text("status_description"),
|
statusDescription: text("status_description"),
|
||||||
startedAt: datetime("started_at", { mode: "string", fsp: 3 }),
|
startedAt: datetime("started_at", { mode: "string", fsp: 3 }),
|
||||||
completedAt: datetime("completed_at", { mode: "string", fsp: 3 }),
|
completedAt: datetime("completed_at", { mode: "string", fsp: 3 }),
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
client_max_body_size 10m;
|
# 批量回填 Excel 会内嵌多篇笔记原图和截图。
|
||||||
|
client_max_body_size 85m;
|
||||||
|
|
||||||
location = /koc {
|
location = /koc {
|
||||||
return 301 /koc/$is_args$args;
|
return 301 /koc/$is_args$args;
|
||||||
@@ -21,7 +22,8 @@ server {
|
|||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_read_timeout 3600s;
|
proxy_read_timeout 3600s;
|
||||||
proxy_send_timeout 3600s;
|
proxy_send_timeout 3600s;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
@@ -30,7 +32,8 @@ server {
|
|||||||
location / {
|
location / {
|
||||||
proxy_pass http://app:3000;
|
proxy_pass http://app:3000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|||||||
BIN
design-qa-comparison.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
design-qa-current-filter.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
design-qa-filter-comparison.png
Normal file
|
After Width: | Height: | Size: 397 KiB |
BIN
design-qa-implementation.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
design-qa-inline-filter-comparison.png
Normal file
|
After Width: | Height: | Size: 376 KiB |
BIN
design-qa-inline-filter.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
design-qa-resource-card-alignment-comparison.png
Normal file
|
After Width: | Height: | Size: 633 KiB |
BIN
design-qa-resource-card-alignment-final.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
design-qa-resource-card-comparison.png
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
design-qa-resource-card-metrics-alignment-comparison.png
Normal file
|
After Width: | Height: | Size: 280 KiB |
BIN
design-qa-resource-card-metrics-alignment-final.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
design-qa-resource-card-metrics-alignment-two-column.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
design-qa-resource-cards-final.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
design-qa-resource-cards-v1.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
design-qa-resource-cards-v2.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
246
design-qa.md
@@ -45,3 +45,249 @@
|
|||||||
3. 重新构建后,浏览器识别到 1 条可点击标题;点击实际打开对应小红书页面,未回填行仍不可点击。
|
3. 重新构建后,浏览器识别到 1 条可点击标题;点击实际打开对应小红书页面,未回填行仍不可点击。
|
||||||
|
|
||||||
final result: passed
|
final result: passed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# KOC LOOP 任务筛选常驻搜索框设计 QA
|
||||||
|
|
||||||
|
## 验证对象
|
||||||
|
|
||||||
|
- 用户目标截图:`/var/folders/0g/8yrmts9s7v5g_xc60sxx0__80000gn/T/codex-clipboard-67b0b080-8ea8-4504-82eb-348e8cf4ec19.png`
|
||||||
|
- 浏览器实现截图:`/private/tmp/koc-distributions-direct-search.png`
|
||||||
|
- CSS 视口:842 × 778,设备像素比 2
|
||||||
|
- 本地页面:`http://localhost:8080/?nav=distributions`
|
||||||
|
|
||||||
|
## 调整结果
|
||||||
|
|
||||||
|
1. 品牌/项目、内容类型、平台不再使用“先点下拉、再输入搜索”的两层结构,筛选栏直接展示三个可输入的搜索框。
|
||||||
|
2. 输入内容时立即进行模糊筛选;聚焦或输入后,下方仅展示匹配候选项,不再重复显示第二个搜索框。
|
||||||
|
3. 宽屏下四个搜索框保持同一行;当前窄视口下任务名称独占一行,三个分类搜索保持在下一行,不互相遮挡。
|
||||||
|
4. 保留键盘与无障碍语义,三个分类搜索均使用 `combobox`,候选项使用 `listbox` / `option`。
|
||||||
|
|
||||||
|
## 功能验证
|
||||||
|
|
||||||
|
- 默认展示 6 个任务。
|
||||||
|
- 品牌/项目直接输入“美团”后,候选项仅显示“美团”和“美团医美”,任务结果同步缩小为 2 个。
|
||||||
|
- 清空输入后恢复 6 个任务。
|
||||||
|
- 静态验收测试 14 项全部通过,正式构建通过;本地 Docker 应用已重新构建并加载新页面。
|
||||||
|
|
||||||
|
final result: passed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# KOC LOOP 任务筛选下拉遮挡设计 QA
|
||||||
|
|
||||||
|
## 验证对象
|
||||||
|
|
||||||
|
- 用户问题截图:`/var/folders/0g/8yrmts9s7v5g_xc60sxx0__80000gn/T/codex-clipboard-bd2d14b7-472f-43a5-b997-1b6ff9528ba1.png`
|
||||||
|
- 浏览器实现截图:`/Users/wufp/Documents/koc-loop/design-qa-inline-filter.png`
|
||||||
|
- 修复前后并排对照:`/Users/wufp/Documents/koc-loop/design-qa-inline-filter-comparison.png`
|
||||||
|
- CSS 视口:1280 × 720,设备像素比 2
|
||||||
|
- 本地页面:`http://localhost:8080/?nav=distributions`
|
||||||
|
|
||||||
|
## 问题与调整
|
||||||
|
|
||||||
|
1. 之前仍使用浏览器原生 `select`,系统级下拉层会悬浮在页面上,因此即使搜索框本身布局正常,展开菜单仍会遮住搜索区或任务卡片。
|
||||||
|
2. 品牌/项目、内容类型、平台三个筛选器改为页面内选择面板,面板使用正常文档流布局,展开时把任务卡片向下推。
|
||||||
|
3. 同一时间只展开一个筛选器;选择选项后自动收起,并保留键盘焦点、`aria-expanded`、`listbox` 和 `option` 语义。
|
||||||
|
|
||||||
|
## 布局与功能验证
|
||||||
|
|
||||||
|
- 内容类型面板展开后:面板 `position: static`,搜索框底部为 379px,面板顶部为 388px,两者无重叠。
|
||||||
|
- 第一张任务卡片顶部为 480px,面板底部为 452px,任务卡片位于面板下方,未被覆盖。
|
||||||
|
- 选择“视频”后面板自动收起,任务数由 6 个缩小为 1 个;清空筛选后恢复 6 个任务。
|
||||||
|
- 浏览器实测搜索框、三个筛选按钮和任务卡片均可正常操作,交互过程中未出现页面报错。
|
||||||
|
- TypeScript 构建、静态验收及共 65 项测试全部通过;本地 Docker 服务已重新构建并通过健康检查。
|
||||||
|
|
||||||
|
final result: passed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# KOC LOOP 任务分发筛选栏设计 QA
|
||||||
|
|
||||||
|
## 验证对象
|
||||||
|
|
||||||
|
- 用户问题截图:`/var/folders/0g/8yrmts9s7v5g_xc60sxx0__80000gn/T/codex-clipboard-4c32c7e3-58e0-4262-b625-c02b20ec4490.png`
|
||||||
|
- 浏览器实现截图:`/Users/wufp/Documents/koc-loop/design-qa-implementation.png`
|
||||||
|
- 修复前后并排对照:`/Users/wufp/Documents/koc-loop/design-qa-comparison.png`
|
||||||
|
- CSS 视口:1280 × 720
|
||||||
|
- 本地页面:`http://localhost:8080/?nav=distributions`
|
||||||
|
|
||||||
|
## 问题与调整
|
||||||
|
|
||||||
|
1. 全局 `label` 布局把搜索图标和输入框纵向堆叠,导致搜索框被拆成上下两层。
|
||||||
|
2. 搜索框现在显式使用横向布局,并清除继承的外边距;图标使用固定宽度居中对齐。
|
||||||
|
3. 品牌/项目下拉框加宽,选中值和下拉菜单锚点保持稳定,不再挤压搜索区域。
|
||||||
|
|
||||||
|
## 功能验证
|
||||||
|
|
||||||
|
- 输入“视频”后,结果从 6 个任务缩小为 1 个任务,筛选栏没有发生错位。
|
||||||
|
- 清空搜索后恢复展示 6 个任务。
|
||||||
|
- 浏览器控制台无错误。
|
||||||
|
- TypeScript、静态验收测试和正式构建均通过。
|
||||||
|
- 本地 Docker 服务已重建并通过健康检查。
|
||||||
|
|
||||||
|
final result: passed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# KOC LOOP 可搜索任务筛选浮层设计 QA
|
||||||
|
|
||||||
|
## 验证对象
|
||||||
|
|
||||||
|
- 参考截图:`/var/folders/0g/8yrmts9s7v5g_xc60sxx0__80000gn/T/codex-clipboard-fc7ce949-d8ea-4a3b-afa6-04a778fdb8dc.png`
|
||||||
|
- 浏览器实现截图:`/Users/wufp/Documents/koc-loop/design-qa-current-filter.png`
|
||||||
|
- 参考与实现并排对照:`/Users/wufp/Documents/koc-loop/design-qa-filter-comparison.png`
|
||||||
|
- 本地页面:`http://localhost:8080/?nav=distributions`
|
||||||
|
|
||||||
|
## 布局与交互验证
|
||||||
|
|
||||||
|
- 品牌/项目、内容类型、平台均改为紧凑的页面内浮层,不再使用系统原生下拉,也不会把任务卡片整体向下推。
|
||||||
|
- 浮层锚定在当前筛选按钮下方,宽度与筛选控件协调,未遮挡任务名称搜索框及其他筛选按钮。
|
||||||
|
- 浮层顶部支持输入模糊查询;输入“医”后只显示“美团医美”,可继续点击完成筛选。
|
||||||
|
- 同一时间只展开一个筛选器,支持点击页面其他位置或按 Esc 收起。
|
||||||
|
- 浏览器实测筛选栏、任务卡片和按钮均保持稳定,没有布局跳动。
|
||||||
|
|
||||||
|
## 数据修复验证
|
||||||
|
|
||||||
|
- 抖音作品重新采集后获得点赞 5,684、收藏 565、转发 6,878、评论 332。
|
||||||
|
- 抖音主页链接改用短链跳转携带的真实 `sec_uid`,不再用数字内部用户 ID 拼接主页。
|
||||||
|
- MCP 的公开主页解析对当前账号仍返回资源不存在,因此公开抖音号、粉丝数和 IP 暂时保持待识别,不再写入错误数据。
|
||||||
|
- 相关自动化测试共 67 项全部通过,本地 Docker 服务已重建并通过健康检查。
|
||||||
|
|
||||||
|
final result: passed
|
||||||
|
# KOC LOOP 任务分发横向搜索框设计 QA
|
||||||
|
|
||||||
|
## 验证对象
|
||||||
|
|
||||||
|
- 用户问题截图:`codex-clipboard-252bf483-c3d1-4d70-abfb-d54587a300a2.png`
|
||||||
|
- 浏览器实现截图:`/private/tmp/koc-loop-distribution-filters-fixed.png`
|
||||||
|
- 本地页面:`http://localhost:8080/?nav=distributions`
|
||||||
|
|
||||||
|
## 问题与调整
|
||||||
|
|
||||||
|
1. 品牌/项目、内容类型、平台三个筛选框使用通用表单标签,继承了纵向排列样式,导致图标和提示文字上下分离。
|
||||||
|
2. 筛选框改为独立容器,并显式使用横向排列和垂直居中。
|
||||||
|
3. 保留原有模糊搜索、筛选逻辑及整体视觉规范。
|
||||||
|
|
||||||
|
## 布局与功能验证
|
||||||
|
|
||||||
|
- 三个筛选框内图标、输入文字与容器的垂直中心误差均为 0px。
|
||||||
|
- 四个搜索框保持同排展示,无相互遮挡、无异常换行、无额外浮层。
|
||||||
|
- 项目构建通过;页面渲染测试 14/14 通过;本地 Docker 运行版本已更新。
|
||||||
|
|
||||||
|
最终结果:通过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# KOC LOOP KOC资源卡片密度优化设计 QA
|
||||||
|
|
||||||
|
## 验证对象
|
||||||
|
|
||||||
|
- 参考卡片:`/var/folders/0g/8yrmts9s7v5g_xc60sxx0__80000gn/T/codex-clipboard-615400a6-0bd1-4110-b280-f7fdc29bf705.png`
|
||||||
|
- 原页面参考:`/var/folders/0g/8yrmts9s7v5g_xc60sxx0__80000gn/T/codex-clipboard-5bf2bfb5-a50e-4c08-a407-192fdcf20b58.png`
|
||||||
|
- 第一轮实现截图:`/Users/wufp/Documents/koc-loop/design-qa-resource-cards-v2.png`
|
||||||
|
- 最终浏览器截图:`/Users/wufp/Documents/koc-loop/design-qa-resource-cards-final.png`
|
||||||
|
- 聚焦卡片并排对照:`/Users/wufp/Documents/koc-loop/design-qa-resource-card-comparison.png`
|
||||||
|
- 本地页面:`http://localhost:8080/`,KOC资源状态
|
||||||
|
|
||||||
|
## 环境与归一化
|
||||||
|
|
||||||
|
- CSS 视口:1280 × 720;设备像素比 2;浏览器截图按 1280 × 720 CSS 像素输出。
|
||||||
|
- 参考卡片像素:478 × 700;最终完整页面截图:1280 × 720。
|
||||||
|
- 聚焦对照将实现中的 304px 宽卡片等比放大到 478px,并与参考卡片并排查看;没有把两张独立截图当作同一对比证据。
|
||||||
|
- 桌面状态为三列卡片;同时验证 960px 两列、640px 一列。
|
||||||
|
|
||||||
|
## 完整画面对比
|
||||||
|
|
||||||
|
- 信息架构采用参考图的“头像 → 身份信息 → 简介 → 标签”顺序,同时保留 KOC LOOP 原有的粉丝、合作发布、合作来源和主页入口。
|
||||||
|
- 卡片高度由旧版的大块分区缩短到首屏实测约 274—278px;三列宽度均为 304px,页面没有横向溢出。
|
||||||
|
- 小红书使用粉红顶部识别线和柔和粉色标签,抖音使用深色顶部识别线,继续沿用现有平台标识。
|
||||||
|
|
||||||
|
## 聚焦区域检查
|
||||||
|
|
||||||
|
- 字体与层级:昵称 14px 并提高字重;账号号、属地、标签和数据从第一轮偏小的 8px 提升至 9—11px,信息仍紧凑但可读性更好。
|
||||||
|
- 间距与布局:圆形头像、昵称与性别保持同一视觉组;账号号、平台和属地收进头像右侧;粉丝、合作发布、最近合作合并为一条浅底数据栏。
|
||||||
|
- 颜色与视觉标记:标签颜色与平台呼应,状态对比足够;卡片 hover 只使用轻微位移和阴影,不改变布局。
|
||||||
|
- 图片与资产:当前账号数据没有头像 URL,因此保留现有首字母头像作为明确的数据缺失状态,没有伪造真人头像;平台标识继续使用项目已有资产。
|
||||||
|
- 文案与内容:真实简介最多展示三行;“未填写简介”“还没有简介”等占位内容统一折叠为“暂无简介”;无标签显示“待打标”;合作来源和主页入口合并到底部同一行。
|
||||||
|
|
||||||
|
## 交互与响应式验证
|
||||||
|
|
||||||
|
- 输入标签“美食探店”后,结果正确缩小为 3 个账号;清空后恢复完整列表。
|
||||||
|
- 主页入口保持可见并保留现有跳转目标;首屏六张卡片均检测到有效入口文案。
|
||||||
|
- 960px 视口为两列,640px 视口为一列,两个断点均无横向溢出。
|
||||||
|
- 浏览器控制台无 error;本地应用、MySQL、Nginx 均正常运行。
|
||||||
|
- 正式构建及完整自动化测试通过,共 82 项,无失败。
|
||||||
|
|
||||||
|
## 迭代记录
|
||||||
|
|
||||||
|
1. 第一轮已完成资料卡层级和高度压缩,但聚焦截图显示辅助文字偏小,无简介账号的底部留白仍较明显,记为 P2。
|
||||||
|
2. 第二轮提高辅助文字字号,移除固定最小高度,并把合作来源与主页入口合并为同一底栏。
|
||||||
|
3. 复查后卡片高度稳定在约 274—278px,关键内容可读,桌面与移动断点无溢出;先前 P2 已解决。
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
- 没有遗留 P0、P1 或 P2 问题。
|
||||||
|
- P3 后续项:如果 MCP 未来提供可靠头像 URL,可将首字母头像替换成真实头像,进一步接近参考图。
|
||||||
|
|
||||||
|
final result: passed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# KOC LOOP KOC资源卡片底栏对齐设计 QA
|
||||||
|
|
||||||
|
## 验证对象
|
||||||
|
|
||||||
|
- 用户标注截图:`/var/folders/0g/8yrmts9s7v5g_xc60sxx0__80000gn/T/codex-clipboard-fa2c031d-52ba-4ef5-bd70-2bf74adfa52d.png`
|
||||||
|
- 最终浏览器截图:`/Users/wufp/Documents/koc-loop/design-qa-resource-card-alignment-final.png`
|
||||||
|
- 归一化并排对照:`/Users/wufp/Documents/koc-loop/design-qa-resource-card-alignment-comparison.png`
|
||||||
|
- 本地页面:`http://localhost:8080/?nav=resources`
|
||||||
|
|
||||||
|
## 问题与调整
|
||||||
|
|
||||||
|
1. 不同账号的简介和标签数量不一致时,合作来源与主页入口会跟随前方内容上下浮动,导致同一排卡片的底部结构不齐。
|
||||||
|
2. 卡片保持纵向弹性布局,底栏改为自动占用剩余空间并贴住卡片底部;不增加固定高度,不改变数据或交互逻辑。
|
||||||
|
3. 三列桌面视口下实测前三张卡片高度均为 267.9px,底栏顶部均为 333.4px,底栏底部均为 365.9px,误差为 0px。
|
||||||
|
|
||||||
|
## 验证结果
|
||||||
|
|
||||||
|
- CSS 视口:1280 × 720;三列卡片状态。
|
||||||
|
- 不同简介、标签数量下,合作来源、来源标签和主页入口保持同一条水平基线。
|
||||||
|
- 浏览器控制台无 error;页面 hover 位移不会改变静止状态的布局基线。
|
||||||
|
- 页面渲染测试与 TypeScript 检查通过;本地 Docker 已重建。
|
||||||
|
|
||||||
|
final result: passed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# KOC LOOP KOC资源卡片数据栏对齐设计 QA
|
||||||
|
|
||||||
|
## 验证对象
|
||||||
|
|
||||||
|
- 用户标注截图:`/var/folders/0g/8yrmts9s7v5g_xc60sxx0__80000gn/T/codex-clipboard-78335120-710a-4a7b-96f9-b1046191c788.png`
|
||||||
|
- 双列实现截图:`/Users/wufp/Documents/koc-loop/design-qa-resource-card-metrics-alignment-two-column.png`
|
||||||
|
- 三列实现截图:`/Users/wufp/Documents/koc-loop/design-qa-resource-card-metrics-alignment-final.png`
|
||||||
|
- 归一化并排对照:`/Users/wufp/Documents/koc-loop/design-qa-resource-card-metrics-alignment-comparison.png`
|
||||||
|
- 本地页面:`http://localhost:8080/?nav=resources`
|
||||||
|
|
||||||
|
## 环境与归一化
|
||||||
|
|
||||||
|
- 用户截图为 1674 × 1180px;双列实现截图为 837 × 591px。
|
||||||
|
- 并排对照将用户截图归一化为 837 × 591px,与实现截图使用同一双列宽度和页面状态进行聚焦比较。
|
||||||
|
- 同时在 1280 × 720 三列桌面视口复测,确认调整不依赖双列断点。
|
||||||
|
|
||||||
|
## 问题与调整
|
||||||
|
|
||||||
|
1. 先前仅把合作来源底栏贴底,数据栏仍跟随简介和标签数量上下浮动,形成 P2 对齐问题。
|
||||||
|
2. 将弹性留白移动到标签区之后,数据栏与合作来源底栏作为一个完整结构贴住卡片底部;数据内容、标签和交互均未改动。
|
||||||
|
|
||||||
|
## 验证结果
|
||||||
|
|
||||||
|
- 双列第一排两张卡片的数据栏顶部均为 233.4px、底部均为 267.9px;底栏顶部均为 277.9px、底部均为 310.4px。
|
||||||
|
- 双列第二排两张卡片的数据栏顶部均为 525.3px、底部均为 559.8px;底栏顶部均为 569.8px、底部均为 602.3px。
|
||||||
|
- 三列前三张卡片的数据栏与底栏上下边界误差均为 0px。
|
||||||
|
- 字体、颜色、图片资产和文案未发生变化;浏览器控制台无 error。
|
||||||
|
- 页面渲染测试 14/14、TypeScript 检查和 Docker 构建均通过。
|
||||||
|
|
||||||
|
final result: passed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# KOC LOOP 私有化部署指南
|
# KOC LOOP 私有化部署指南
|
||||||
|
|
||||||
本文适用于 `codex/self-hosted-mysql` 分支。目标架构是运维提出的:Nginx 代理 + KOC 服务 + MySQL 数据库,并让后台、外部 KOC 领取页和 Agent MCP 都能通过一个公网域名访问。
|
本文适用于 `main` 分支。目标架构是运维提出的:Nginx 代理 + KOC 服务 + MySQL 数据库,并让后台、外部 KOC 领取页和 Agent MCP 都能通过一个公网域名访问。
|
||||||
|
|
||||||
## 1. 部署形态
|
## 1. 部署形态
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
```bash
|
```bash
|
||||||
git clone ssh://git@gta.gbotai.cn:42001/wufengping/koc-loop.git
|
git clone ssh://git@gta.gbotai.cn:42001/wufengping/koc-loop.git
|
||||||
cd koc-loop
|
cd koc-loop
|
||||||
git checkout codex/self-hosted-mysql
|
git checkout main
|
||||||
cp .env.self-hosted.example .env.self-hosted
|
cp .env.self-hosted.example .env.self-hosted
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,6 +57,8 @@ cp .env.self-hosted.example .env.self-hosted
|
|||||||
|
|
||||||
密钥必须由密码管理器生成,禁止提交到 Git、聊天、部署日志或 URL。三个业务密钥 `ADMIN_INTERNAL_TOKEN`、`KOC_MCP_API_KEY`、`AI_TOOL_CENTER_MCP_KEY` 不得复用。
|
密钥必须由密码管理器生成,禁止提交到 Git、聊天、部署日志或 URL。三个业务密钥 `ADMIN_INTERNAL_TOKEN`、`KOC_MCP_API_KEY`、`AI_TOOL_CENTER_MCP_KEY` 不得复用。
|
||||||
|
|
||||||
|
`APP_ORIGIN` 必须填写用户实际访问的 HTTPS 公网地址,不能填写 `localhost`、`app:3000` 或其他容器内部地址。Excel 中的视频下载链接会优先使用这个地址;前置网关还必须把原始 `Host`、`X-Forwarded-Host` 和 `X-Forwarded-Proto` 传给仓库内的 Nginx。
|
||||||
|
|
||||||
## 4. 首次启动
|
## 4. 首次启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -88,7 +90,9 @@ curl -fsS http://127.0.0.1:${HTTP_PORT:-80}/api/health
|
|||||||
3. 80 端口只做 301 跳转;
|
3. 80 端口只做 301 跳转;
|
||||||
4. 保留 `/koc/` 静态规则、`/api/mcp` 长连接规则和 `/` 反向代理规则。
|
4. 保留 `/koc/` 静态规则、`/api/mcp` 长连接规则和 `/` 反向代理规则。
|
||||||
|
|
||||||
MCP 路由已经关闭代理缓冲并将超时时间延长到 1 小时,避免 Agent 的长调用被 Nginx 提前截断。
|
MCP 路由已经关闭代理缓冲并将超时时间延长到 1 小时,避免 Agent 的长调用被 Nginx 提前截断。仓库内 Nginx 同时将上传限制设为 85 MB,用于接收最多 80 MB 的批量回填 Excel;公司网关或负载均衡的请求体限制也必须不低于 85 MB。
|
||||||
|
|
||||||
|
视频下载接口必须经过 `/api/partner-image` 反向代理,正常响应应包含 `Content-Type: video/mp4` 和带 `.mp4` 文件名的 `Content-Disposition: attachment`。不要在网关层改写该响应类型或移除附件响应头。
|
||||||
|
|
||||||
## 6. 迁移原 Sites 数据
|
## 6. 迁移原 Sites 数据
|
||||||
|
|
||||||
@@ -124,12 +128,13 @@ npm run db:import-json -- /backup/koc-d1-export.json
|
|||||||
|
|
||||||
导入脚本按业务依赖顺序写入,并使用主键/唯一键安全更新已有记录。正式迁移前先在测试库演练并核对任务数、笔记数、领取数、发布数和账号数。
|
导入脚本按业务依赖顺序写入,并使用主键/唯一键安全更新已有记录。正式迁移前先在测试库演练并核对任务数、笔记数、领取数、发布数和账号数。
|
||||||
|
|
||||||
### 6.2 R2 图片导入
|
### 6.2 R2 媒体文件导入
|
||||||
|
|
||||||
把 R2 按原对象 key 导出到一个目录,目录层级必须保留,例如:
|
把 R2 按原对象 key 导出到一个目录,目录层级必须保留,例如:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
content-assets/...
|
content-assets/...
|
||||||
|
content-videos/...
|
||||||
publish-evidence/...
|
publish-evidence/...
|
||||||
creator-center/...
|
creator-center/...
|
||||||
```
|
```
|
||||||
@@ -141,7 +146,7 @@ UPLOAD_DIR=/data/koc/uploads \
|
|||||||
npm run storage:import -- /backup/koc-r2-export
|
npm run storage:import -- /backup/koc-r2-export
|
||||||
```
|
```
|
||||||
|
|
||||||
脚本会复制文件,并为缺少元数据的图片生成 Content-Type 元数据。容器部署时也可以在宿主机临时挂载 `upload_data` 卷后执行。
|
脚本会复制文件,并为缺少元数据的媒体文件生成 Content-Type 元数据。容器部署时也可以在宿主机临时挂载 `upload_data` 卷后执行。
|
||||||
|
|
||||||
## 7. 上线验收
|
## 7. 上线验收
|
||||||
|
|
||||||
@@ -155,9 +160,11 @@ UPLOAD_DIR=/data/koc/uploads \
|
|||||||
6. 上传创作者截图并填写曝光量、阅读量;
|
6. 上传创作者截图并填写曝光量、阅读量;
|
||||||
7. 后台立即采集一篇笔记成功;
|
7. 后台立即采集一篇笔记成功;
|
||||||
8. 保存次日采集计划,确认数据库产生 `collection_runs`;
|
8. 保存次日采集计划,确认数据库产生 `collection_runs`;
|
||||||
9. 导出的 Excel 内能直接看到原图和截图;
|
9. 图文任务导出的 Excel 内能直接看到原图和截图;
|
||||||
10. Agent 用 `KOC_MCP_API_KEY` 调用 `/api/mcp` 能发现全部工具;
|
10. 视频任务导出的 Excel 不含“图片”列,包含“视频”列,点击链接能下载扩展名为 `.mp4` 且可正常播放的文件;
|
||||||
11. 重启全部容器后数据与图片不丢失。
|
11. 批量回填 Excel 可以上传,发布链接、笔记截图和单篇笔记数据分析截图均能正确回写;
|
||||||
|
12. Agent 用 `KOC_MCP_API_KEY` 调用 `/api/mcp` 能发现全部工具;
|
||||||
|
13. 重启全部容器后数据、图片和视频不丢失。
|
||||||
|
|
||||||
## 8. 备份与恢复
|
## 8. 备份与恢复
|
||||||
|
|
||||||
@@ -180,6 +187,8 @@ docker compose --env-file .env.self-hosted \
|
|||||||
-f docker-compose.self-hosted.yml up -d --build
|
-f docker-compose.self-hosted.yml up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
应用容器每次启动都会按文件名顺序执行尚未应用的 `mysql/*.sql`。本次版本包含平台/视频字段、账号性别/简介/标签以及“当前联系人”字段的增量迁移;升级后应检查容器日志确认 `0005`、`0006`、`0007` 已执行或已被识别为历史迁移。
|
||||||
|
|
||||||
数据库迁移只允许向前追加新的 `mysql/*.sql` 文件,禁止修改已经在生产执行过的迁移。应用回滚到旧镜像前,要确认旧代码兼容当前数据库结构;涉及不可逆结构变化时,必须同时准备数据库恢复方案。
|
数据库迁移只允许向前追加新的 `mysql/*.sql` 文件,禁止修改已经在生产执行过的迁移。应用回滚到旧镜像前,要确认旧代码兼容当前数据库结构;涉及不可逆结构变化时,必须同时准备数据库恢复方案。
|
||||||
|
|
||||||
## 10. 运维排查
|
## 10. 运维排查
|
||||||
@@ -189,6 +198,9 @@ docker compose --env-file .env.self-hosted \
|
|||||||
| `/api/health` 返回 503 | 查看 MySQL 容器健康状态与应用数据库变量 |
|
| `/api/health` 返回 503 | 查看 MySQL 容器健康状态与应用数据库变量 |
|
||||||
| 登录页可开但登录失败 | 确认迁移完成、超级管理员变量仅用于初始化 |
|
| 登录页可开但登录失败 | 确认迁移完成、超级管理员变量仅用于初始化 |
|
||||||
| 配图或截图 404 | 检查 `upload_data` 卷和 `UPLOAD_DIR=/data/koc/uploads` |
|
| 配图或截图 404 | 检查 `upload_data` 卷和 `UPLOAD_DIR=/data/koc/uploads` |
|
||||||
|
| Excel 视频链接出现 localhost 或无法访问 | 检查 `APP_ORIGIN`、公网域名和网关转发的 Host/Proto 请求头 |
|
||||||
|
| 视频下载后不是 MP4 或无法播放 | 检查 `/api/partner-image` 是否经过应用代理、文件是否完整,以及网关是否保留 Content-Type/Content-Disposition |
|
||||||
|
| 批量回填表上传返回 413 | 将公司网关、负载均衡和 Nginx 的请求体限制统一提高到至少 85 MB |
|
||||||
| 09:00 未自动采集 | 检查 `ENABLE_SCHEDULER=true`、服务器日志和采集 MCP 网络 |
|
| 09:00 未自动采集 | 检查 `ENABLE_SCHEDULER=true`、服务器日志和采集 MCP 网络 |
|
||||||
| MCP 401 | 检查请求头是否为 `Authorization: Bearer <KOC_MCP_API_KEY>` |
|
| MCP 401 | 检查请求头是否为 `Authorization: Bearer <KOC_MCP_API_KEY>` |
|
||||||
| 飞书读取失败 | 检查应用权限、文档授权和服务器到飞书 OpenAPI 的网络 |
|
| 飞书读取失败 | 检查应用权限、文档授权和服务器到飞书 OpenAPI 的网络 |
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
# KOC LOOP 部署指南
|
# KOC LOOP Sites 旧版部署指南
|
||||||
|
|
||||||
|
> 此文档仅适用于历史 `codex/sites-release-controls` 分支。`main` 已切换为 Next.js + MySQL + Nginx 私有化部署,正式部署请使用 [KOC LOOP 私有化部署指南](KOC%20LOOP%20私有化部署指南.md),不要按本文把 `main` 发布到 Sites。
|
||||||
|
|
||||||
KOC LOOP 由两个独立站点组成:
|
KOC LOOP 由两个独立站点组成:
|
||||||
|
|
||||||
|
|||||||
341
koc-portal/app/batch-workbook-upload.ts
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import { strFromU8, unzipSync, zipSync } from "fflate";
|
||||||
|
|
||||||
|
export const PARTNER_BATCH_UPLOAD_MAX_BYTES = 80_000_000;
|
||||||
|
|
||||||
|
type WorkbookCell = {
|
||||||
|
reference: string;
|
||||||
|
row: number;
|
||||||
|
column: number;
|
||||||
|
attributes: string;
|
||||||
|
body: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ScreenshotColumns = {
|
||||||
|
headerRow: number;
|
||||||
|
columns: Set<number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function decodeXml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/<[^>]+>/g, "")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
||||||
|
.replace(/&#x([0-9a-f]+);/gi, (_, code) =>
|
||||||
|
String.fromCodePoint(Number.parseInt(code, 16)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function textNodes(xml: string) {
|
||||||
|
return [...xml.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)]
|
||||||
|
.map((match) => decodeXml(match[1]))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnIndex(reference: string) {
|
||||||
|
const letters = reference.match(/^[A-Z]+/i)?.[0]?.toUpperCase() ?? "";
|
||||||
|
let result = 0;
|
||||||
|
for (const letter of letters) result = result * 26 + letter.charCodeAt(0) - 64;
|
||||||
|
return Math.max(0, result - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeader(value: string) {
|
||||||
|
return value.replace(/[\s_\-()()]/g, "").toLocaleLowerCase("zh-CN");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCells(worksheetXml: string, sharedStrings: string[]) {
|
||||||
|
const cells: WorkbookCell[] = [];
|
||||||
|
for (const match of worksheetXml.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
|
||||||
|
const attributes = match[1];
|
||||||
|
const body = match[2];
|
||||||
|
const reference = attributes.match(/\br="([A-Z]+\d+)"/i)?.[1] ?? "";
|
||||||
|
if (!reference) continue;
|
||||||
|
const type = attributes.match(/\bt="([^"]+)"/)?.[1] ?? "";
|
||||||
|
const rawValue = body.match(/<v>([\s\S]*?)<\/v>/)?.[1] ?? "";
|
||||||
|
const value =
|
||||||
|
type === "s"
|
||||||
|
? sharedStrings[Number(rawValue)] ?? ""
|
||||||
|
: type === "inlineStr"
|
||||||
|
? textNodes(body)
|
||||||
|
: decodeXml(rawValue);
|
||||||
|
cells.push({
|
||||||
|
reference,
|
||||||
|
row: Number(reference.match(/\d+$/)?.[0] ?? 0),
|
||||||
|
column: columnIndex(reference),
|
||||||
|
attributes,
|
||||||
|
body,
|
||||||
|
value: value.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findScreenshotColumns(cells: WorkbookCell[]): ScreenshotColumns {
|
||||||
|
for (let row = 1; row <= 8; row += 1) {
|
||||||
|
const columns = new Set<number>();
|
||||||
|
for (const cell of cells) {
|
||||||
|
if (cell.row !== row) continue;
|
||||||
|
const header = normalizeHeader(cell.value);
|
||||||
|
if (
|
||||||
|
header === normalizeHeader("笔记截图") ||
|
||||||
|
header === normalizeHeader("发布截图") ||
|
||||||
|
header === normalizeHeader("数据分析截图") ||
|
||||||
|
header === normalizeHeader("数据分析截图(单篇笔记数据分析截图)") ||
|
||||||
|
header === normalizeHeader("创作者中心截图")
|
||||||
|
) {
|
||||||
|
columns.add(cell.column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (columns.size >= 2) return { headerRow: row, columns };
|
||||||
|
}
|
||||||
|
throw new Error("表格结构不正确,请使用本领取页面导出的批量回填表");
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationshipMap(xml: string) {
|
||||||
|
const relationships = new Map<string, string>();
|
||||||
|
for (const match of xml.matchAll(/<Relationship\b([^>]*)\/?\s*>/g)) {
|
||||||
|
const id = match[1].match(/\bId="([^"]+)"/)?.[1] ?? "";
|
||||||
|
const target = match[1].match(/\bTarget="([^"]+)"/)?.[1] ?? "";
|
||||||
|
if (id && target) relationships.set(id, decodeXml(target));
|
||||||
|
}
|
||||||
|
return relationships;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeZipPath(value: string) {
|
||||||
|
const result: string[] = [];
|
||||||
|
for (const part of value.split("/")) {
|
||||||
|
if (!part || part === ".") continue;
|
||||||
|
if (part === "..") result.pop();
|
||||||
|
else result.push(part);
|
||||||
|
}
|
||||||
|
return result.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveZipPath(base: string, target: string) {
|
||||||
|
const slash = base.lastIndexOf("/");
|
||||||
|
const directory = slash >= 0 ? base.slice(0, slash + 1) : "";
|
||||||
|
return normalizeZipPath(`${directory}${target}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wpsScreenshotMedia(
|
||||||
|
entries: Record<string, Uint8Array>,
|
||||||
|
cells: WorkbookCell[],
|
||||||
|
screenshotColumns: ScreenshotColumns,
|
||||||
|
) {
|
||||||
|
const result = new Set<string>();
|
||||||
|
let expected = 0;
|
||||||
|
const screenshotIds = new Set<string>();
|
||||||
|
for (const cell of cells) {
|
||||||
|
if (
|
||||||
|
cell.row <= screenshotColumns.headerRow ||
|
||||||
|
!screenshotColumns.columns.has(cell.column)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const id = decodeXml(cell.body).match(/DISPIMG\("([^"]+)"/i)?.[1];
|
||||||
|
if (id) {
|
||||||
|
expected += 1;
|
||||||
|
screenshotIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (screenshotIds.size === 0) return { result, expected, resolved: 0 };
|
||||||
|
|
||||||
|
const cellImagesXml = entries["xl/cellimages.xml"]
|
||||||
|
? strFromU8(entries["xl/cellimages.xml"])
|
||||||
|
: "";
|
||||||
|
const relationships = relationshipMap(
|
||||||
|
entries["xl/_rels/cellimages.xml.rels"]
|
||||||
|
? strFromU8(entries["xl/_rels/cellimages.xml.rels"])
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
let resolved = 0;
|
||||||
|
for (const match of cellImagesXml.matchAll(
|
||||||
|
/<(?:etc:)?cellImage\b[^>]*>([\s\S]*?)<\/(?:etc:)?cellImage>/g,
|
||||||
|
)) {
|
||||||
|
const id = match[1].match(/<(?:xdr:)?cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
||||||
|
const relationshipId = match[1].match(
|
||||||
|
/<(?:a:)?blip\b[^>]*\br:embed="([^"]+)"/,
|
||||||
|
)?.[1];
|
||||||
|
if (!id || !relationshipId || !screenshotIds.has(id)) continue;
|
||||||
|
const target = relationships.get(relationshipId);
|
||||||
|
if (!target) continue;
|
||||||
|
result.add(resolveZipPath("xl/cellimages.xml", target));
|
||||||
|
resolved += 1;
|
||||||
|
}
|
||||||
|
return { result, expected, resolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawingScreenshotMedia(
|
||||||
|
entries: Record<string, Uint8Array>,
|
||||||
|
screenshotColumns: ScreenshotColumns,
|
||||||
|
) {
|
||||||
|
const result = new Set<string>();
|
||||||
|
let expected = 0;
|
||||||
|
let resolved = 0;
|
||||||
|
const worksheetXml = entries["xl/worksheets/sheet1.xml"]
|
||||||
|
? strFromU8(entries["xl/worksheets/sheet1.xml"])
|
||||||
|
: "";
|
||||||
|
const sheetRelationships = relationshipMap(
|
||||||
|
entries["xl/worksheets/_rels/sheet1.xml.rels"]
|
||||||
|
? strFromU8(entries["xl/worksheets/_rels/sheet1.xml.rels"])
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
const drawingId = worksheetXml.match(/<drawing\b[^>]*r:id="([^"]+)"/)?.[1];
|
||||||
|
const drawingTarget = drawingId ? sheetRelationships.get(drawingId) : undefined;
|
||||||
|
if (!drawingTarget) return { result, expected, resolved };
|
||||||
|
const drawingPath = resolveZipPath("xl/worksheets/sheet1.xml", drawingTarget);
|
||||||
|
const drawingXml = entries[drawingPath] ? strFromU8(entries[drawingPath]) : "";
|
||||||
|
const relationshipPath = `${drawingPath.slice(0, drawingPath.lastIndexOf("/") + 1)}_rels/${drawingPath.slice(drawingPath.lastIndexOf("/") + 1)}.rels`;
|
||||||
|
const drawingRelationships = relationshipMap(
|
||||||
|
entries[relationshipPath] ? strFromU8(entries[relationshipPath]) : "",
|
||||||
|
);
|
||||||
|
for (const anchor of drawingXml.matchAll(
|
||||||
|
/<xdr:(?:oneCellAnchor|twoCellAnchor)\b[^>]*>[\s\S]*?<xdr:from>([\s\S]*?)<\/xdr:from>[\s\S]*?<a:blip\b[^>]*r:embed="([^"]+)"[\s\S]*?<\/xdr:(?:oneCellAnchor|twoCellAnchor)>/g,
|
||||||
|
)) {
|
||||||
|
const column = Number(anchor[1].match(/<xdr:col>(\d+)<\/xdr:col>/)?.[1]);
|
||||||
|
const zeroBasedRow = Number(anchor[1].match(/<xdr:row>(\d+)<\/xdr:row>/)?.[1]);
|
||||||
|
if (
|
||||||
|
!Number.isInteger(column) ||
|
||||||
|
!Number.isInteger(zeroBasedRow) ||
|
||||||
|
zeroBasedRow + 1 <= screenshotColumns.headerRow ||
|
||||||
|
!screenshotColumns.columns.has(column)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
expected += 1;
|
||||||
|
const target = drawingRelationships.get(anchor[2]);
|
||||||
|
if (!target) continue;
|
||||||
|
result.add(resolveZipPath(drawingPath, target));
|
||||||
|
resolved += 1;
|
||||||
|
}
|
||||||
|
return { result, expected, resolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
function richValueScreenshotMedia(
|
||||||
|
entries: Record<string, Uint8Array>,
|
||||||
|
cells: WorkbookCell[],
|
||||||
|
screenshotColumns: ScreenshotColumns,
|
||||||
|
) {
|
||||||
|
const result = new Set<string>();
|
||||||
|
let expected = 0;
|
||||||
|
let resolved = 0;
|
||||||
|
const metadataXml = entries["xl/metadata.xml"]
|
||||||
|
? strFromU8(entries["xl/metadata.xml"])
|
||||||
|
: "";
|
||||||
|
const richValueXml = entries["xl/richData/rdrichvalue.xml"]
|
||||||
|
? strFromU8(entries["xl/richData/rdrichvalue.xml"])
|
||||||
|
: "";
|
||||||
|
const richValueRelXml = entries["xl/richData/richValueRel.xml"]
|
||||||
|
? strFromU8(entries["xl/richData/richValueRel.xml"])
|
||||||
|
: "";
|
||||||
|
const relationships = relationshipMap(
|
||||||
|
entries["xl/richData/_rels/richValueRel.xml.rels"]
|
||||||
|
? strFromU8(entries["xl/richData/_rels/richValueRel.xml.rels"])
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
if (!metadataXml || !richValueXml || !richValueRelXml || !relationships.size) {
|
||||||
|
return { result, expected, resolved };
|
||||||
|
}
|
||||||
|
const valueMetadataXml =
|
||||||
|
metadataXml.match(/<valueMetadata\b[^>]*>([\s\S]*?)<\/valueMetadata>/)?.[1] ??
|
||||||
|
"";
|
||||||
|
const metadataToRichValue = [
|
||||||
|
...valueMetadataXml.matchAll(
|
||||||
|
/<bk\b[^>]*>[\s\S]*?<rc\b[^>]*\bv="(\d+)"[^>]*\/>[\s\S]*?<\/bk>/g,
|
||||||
|
),
|
||||||
|
].map((match) => Number(match[1]));
|
||||||
|
const richValueToRelationship = [
|
||||||
|
...richValueXml.matchAll(/<rv\b[^>]*>([\s\S]*?)<\/rv>/g),
|
||||||
|
].map((match) => Number(match[1].match(/<v>(\d+)<\/v>/)?.[1] ?? -1));
|
||||||
|
const relationshipIds = [
|
||||||
|
...richValueRelXml.matchAll(/<rel\b[^>]*\br:id="([^"]+)"[^>]*\/>/g),
|
||||||
|
].map((match) => match[1]);
|
||||||
|
|
||||||
|
for (const cell of cells) {
|
||||||
|
if (
|
||||||
|
cell.row <= screenshotColumns.headerRow ||
|
||||||
|
!screenshotColumns.columns.has(cell.column)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const metadataIndex = Number(cell.attributes.match(/\bvm="(\d+)"/)?.[1] ?? 0);
|
||||||
|
if (!metadataIndex) continue;
|
||||||
|
expected += 1;
|
||||||
|
const richValueIndex = metadataToRichValue[metadataIndex - 1];
|
||||||
|
const relationshipIndex = richValueToRelationship[richValueIndex];
|
||||||
|
const relationshipId = relationshipIds[relationshipIndex];
|
||||||
|
const target = relationships.get(relationshipId);
|
||||||
|
if (!target) continue;
|
||||||
|
result.add(resolveZipPath("xl/richData/richValueRel.xml", target));
|
||||||
|
resolved += 1;
|
||||||
|
}
|
||||||
|
return { result, expected, resolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompactedPartnerBatchWorkbook = {
|
||||||
|
bytes: Uint8Array;
|
||||||
|
removedMediaCount: number;
|
||||||
|
preservedScreenshotCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oversized exports are usually caused by full-resolution source images. The
|
||||||
|
* upload only needs the two screenshot columns, so retain those image entries
|
||||||
|
* and omit source media from the temporary upload copy.
|
||||||
|
*/
|
||||||
|
export function compactPartnerBatchWorkbookForUpload(
|
||||||
|
input: Uint8Array,
|
||||||
|
): CompactedPartnerBatchWorkbook {
|
||||||
|
const isMediaFile = (name: string) =>
|
||||||
|
name.startsWith("xl/media/") && !name.endsWith("/");
|
||||||
|
const structure = unzipSync(input, {
|
||||||
|
filter: (file) => !isMediaFile(file.name),
|
||||||
|
});
|
||||||
|
const worksheetXml = structure["xl/worksheets/sheet1.xml"]
|
||||||
|
? strFromU8(structure["xl/worksheets/sheet1.xml"])
|
||||||
|
: "";
|
||||||
|
if (!worksheetXml) throw new Error("Excel 中没有可读取的批量回填工作表");
|
||||||
|
const sharedXml = structure["xl/sharedStrings.xml"]
|
||||||
|
? strFromU8(structure["xl/sharedStrings.xml"])
|
||||||
|
: "";
|
||||||
|
const sharedStrings = [
|
||||||
|
...sharedXml.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g),
|
||||||
|
].map((match) => textNodes(match[1]));
|
||||||
|
const cells = parseCells(worksheetXml, sharedStrings);
|
||||||
|
const screenshotColumns = findScreenshotColumns(cells);
|
||||||
|
const formats = [
|
||||||
|
wpsScreenshotMedia(structure, cells, screenshotColumns),
|
||||||
|
drawingScreenshotMedia(structure, screenshotColumns),
|
||||||
|
richValueScreenshotMedia(structure, cells, screenshotColumns),
|
||||||
|
];
|
||||||
|
const screenshotMedia = new Set<string>();
|
||||||
|
let expectedScreenshotCount = 0;
|
||||||
|
let resolvedScreenshotCount = 0;
|
||||||
|
for (const format of formats) {
|
||||||
|
expectedScreenshotCount += format.expected;
|
||||||
|
resolvedScreenshotCount += format.resolved;
|
||||||
|
for (const name of format.result) screenshotMedia.add(name);
|
||||||
|
}
|
||||||
|
if (resolvedScreenshotCount < expectedScreenshotCount) {
|
||||||
|
throw new Error("表格中的回填截图无法完整识别,请重新导出最新版回填表");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mediaCount = 0;
|
||||||
|
const entries = unzipSync(input, {
|
||||||
|
filter: (file) => {
|
||||||
|
if (!isMediaFile(file.name)) return true;
|
||||||
|
mediaCount += 1;
|
||||||
|
return screenshotMedia.has(file.name);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const bytes = zipSync(entries, { level: 6 });
|
||||||
|
return {
|
||||||
|
bytes,
|
||||||
|
removedMediaCount: Math.max(0, mediaCount - screenshotMedia.size),
|
||||||
|
preservedScreenshotCount: screenshotMedia.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -48,6 +48,104 @@ button:disabled {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.platform-badge {
|
||||||
|
display: inline-flex !important;
|
||||||
|
width: auto !important;
|
||||||
|
height: 24px;
|
||||||
|
align-items: center;
|
||||||
|
flex: none;
|
||||||
|
gap: 5px;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 2px 7px 2px 3px;
|
||||||
|
border: 1px solid #e1e7e4;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #52615c !important;
|
||||||
|
background: rgb(255 255 255 / 0.92);
|
||||||
|
font-size: 9px !important;
|
||||||
|
font-weight: 720;
|
||||||
|
line-height: 1 !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge.compact {
|
||||||
|
height: 19px;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 5px 2px 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-logo {
|
||||||
|
display: grid !important;
|
||||||
|
width: 18px !important;
|
||||||
|
height: 18px !important;
|
||||||
|
place-items: center;
|
||||||
|
flex: none;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0 !important;
|
||||||
|
border-radius: 5px;
|
||||||
|
line-height: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge.compact .platform-logo {
|
||||||
|
width: 14px !important;
|
||||||
|
height: 14px !important;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-logo.xiaohongshu {
|
||||||
|
color: white !important;
|
||||||
|
background: #ff2442;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-logo.xiaohongshu b {
|
||||||
|
color: inherit;
|
||||||
|
font-size: 5px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.12em;
|
||||||
|
transform: translateX(-0.2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge.compact .platform-logo.xiaohongshu b {
|
||||||
|
font-size: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-logo.douyin {
|
||||||
|
background: #080b12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-logo.douyin svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge.compact .platform-logo.douyin svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-logo.douyin .douyin-cyan { fill: #25f4ee; transform: translate(-0.7px, 0.7px); }
|
||||||
|
.platform-logo.douyin .douyin-red { fill: #fe2c55; transform: translate(0.7px, -0.3px); }
|
||||||
|
.platform-logo.douyin .douyin-white { fill: #fff; }
|
||||||
|
|
||||||
|
.platform-meta-line,
|
||||||
|
.hero-platform-line {
|
||||||
|
display: inline-flex !important;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-meta-line > span,
|
||||||
|
.hero-platform-line > * {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-platform-line {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.portal-shell {
|
.portal-shell {
|
||||||
width: min(100%, 1120px);
|
width: min(100%, 1120px);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
@@ -679,6 +777,15 @@ footer {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-workbook-input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.share-composer {
|
.share-composer {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(190px, 0.45fr) auto;
|
grid-template-columns: minmax(0, 1fr) minmax(190px, 0.45fr) auto;
|
||||||
@@ -843,6 +950,34 @@ footer {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note-thumb.platform-video-thumb {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge.logo-only {
|
||||||
|
width: 34px !important;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge.logo-only .platform-logo {
|
||||||
|
width: 34px !important;
|
||||||
|
height: 34px !important;
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge.logo-only .platform-logo.douyin svg {
|
||||||
|
width: 29px;
|
||||||
|
height: 29px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-badge.logo-only .platform-logo.xiaohongshu b {
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.note-index {
|
.note-index {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 34px;
|
width: 34px;
|
||||||
@@ -1135,12 +1270,32 @@ footer {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-images {
|
.note-images,
|
||||||
|
.note-videos {
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
padding-top: 24px;
|
padding-top: 24px;
|
||||||
border-top: 1px solid #edf0ee;
|
border-top: 1px solid #edf0ee;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note-video-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-video-card {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e4e9e6;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #102a22;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-video-card video {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 680px;
|
||||||
|
background: #0b1f19;
|
||||||
|
}
|
||||||
|
|
||||||
.note-images-heading {
|
.note-images-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -1234,7 +1389,8 @@ footer {
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-image-actions button {
|
.note-image-actions button,
|
||||||
|
.note-image-actions a {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border: 1px solid #cfe1d9;
|
border: 1px solid #cfe1d9;
|
||||||
@@ -1243,9 +1399,12 @@ footer {
|
|||||||
background: #f2f8f5;
|
background: #f2f8f5;
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
font-weight: 680;
|
font-weight: 680;
|
||||||
|
line-height: 26px;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-image-actions button:hover {
|
.note-image-actions button:hover,
|
||||||
|
.note-image-actions a:hover {
|
||||||
border-color: #9fc9b8;
|
border-color: #9fc9b8;
|
||||||
background: #eaf5f0;
|
background: #eaf5f0;
|
||||||
}
|
}
|
||||||
@@ -1633,6 +1792,11 @@ footer {
|
|||||||
font-weight: 620;
|
font-weight: 620;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toast.success {
|
||||||
|
color: var(--green-deep);
|
||||||
|
font-weight: 720;
|
||||||
|
}
|
||||||
|
|
||||||
.loading-shell {
|
.loading-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1782,9 +1946,14 @@ footer {
|
|||||||
|
|
||||||
.section-actions {
|
.section-actions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-actions > span {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.share-composer {
|
.share-composer {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
formatShanghaiDate as formatDate,
|
formatShanghaiDate as formatDate,
|
||||||
parseStoredDate,
|
parseStoredDate,
|
||||||
} from "./date-utils";
|
} from "./date-utils";
|
||||||
|
import {
|
||||||
|
compactPartnerBatchWorkbookForUpload,
|
||||||
|
PARTNER_BATCH_UPLOAD_MAX_BYTES,
|
||||||
|
} from "./batch-workbook-upload";
|
||||||
|
|
||||||
type Assignment = {
|
type Assignment = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,6 +34,11 @@ type Assignment = {
|
|||||||
width: number | null;
|
width: number | null;
|
||||||
height: number | null;
|
height: number | null;
|
||||||
}>;
|
}>;
|
||||||
|
videos: Array<{
|
||||||
|
index: number;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DelegationSummary = {
|
type DelegationSummary = {
|
||||||
@@ -63,6 +72,8 @@ type TaskPayload = {
|
|||||||
dueAt: string;
|
dueAt: string;
|
||||||
status: string;
|
status: string;
|
||||||
type: "content_publish" | "screenshot_collect";
|
type: "content_publish" | "screenshot_collect";
|
||||||
|
platform: "小红书" | "抖音";
|
||||||
|
contentFormat: "image_text" | "video";
|
||||||
};
|
};
|
||||||
claim: null | {
|
claim: null | {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -92,7 +103,12 @@ function resolveAdminOrigin() {
|
|||||||
return window.location.origin;
|
return window.location.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
function partnerApi(path: "/api/partner" | "/api/partner-upload") {
|
function partnerApi(
|
||||||
|
path:
|
||||||
|
| "/api/partner"
|
||||||
|
| "/api/partner-upload"
|
||||||
|
| "/api/partner-batch-workbook",
|
||||||
|
) {
|
||||||
return `${resolveAdminOrigin()}${path}`;
|
return `${resolveAdminOrigin()}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +127,11 @@ function statusLabel(item: Assignment, taskType = "content_publish") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MAX_TASK_RESULT_SCREENSHOTS = 9;
|
const MAX_TASK_RESULT_SCREENSHOTS = 9;
|
||||||
|
const PUBLISH_BACKFILL_SUCCESS = "这篇笔记的发布记录回填成功啦~";
|
||||||
|
|
||||||
|
function toastClassName(message: string) {
|
||||||
|
return message === PUBLISH_BACKFILL_SUCCESS ? "toast success" : "toast";
|
||||||
|
}
|
||||||
|
|
||||||
function resultScreenshotKeys(value: string | null) {
|
function resultScreenshotKeys(value: string | null) {
|
||||||
const text = String(value ?? "").trim();
|
const text = String(value ?? "").trim();
|
||||||
@@ -155,6 +176,36 @@ function safeFileBase(item: Assignment) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlatformBadge({
|
||||||
|
platform,
|
||||||
|
compact = false,
|
||||||
|
logoOnly = false,
|
||||||
|
}: {
|
||||||
|
platform: "小红书" | "抖音";
|
||||||
|
compact?: boolean;
|
||||||
|
logoOnly?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`platform-badge ${compact ? "compact" : ""} ${logoOnly ? "logo-only" : ""}`}
|
||||||
|
aria-label={logoOnly ? platform : undefined}
|
||||||
|
>
|
||||||
|
<span className={`platform-logo ${platform === "抖音" ? "douyin" : "xiaohongshu"}`} aria-hidden="true">
|
||||||
|
{platform === "抖音" ? (
|
||||||
|
<svg viewBox="0 0 24 24" focusable="false">
|
||||||
|
<path className="douyin-cyan" d="M14.2 3.2v9.5a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V3.2h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1V10c-1.7-.1-3.2-.7-4.3-1.7V3.2Z" />
|
||||||
|
<path className="douyin-red" d="M15.2 2.5V12a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V2.5h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1v2.7c-1.7-.1-3.2-.7-4.3-1.7V2.5Z" />
|
||||||
|
<path className="douyin-white" d="M14.7 2.9v9.5a4.7 4.7 0 1 1-3.4-4.5v2.7a2.1 2.1 0 1 0 .8 1.7V2.9h2.6Zm0 0c.4 2.4 2 3.8 4.3 4.1v2.7c-1.7-.1-3.2-.7-4.3-1.7V2.9Z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<b>小红书</b>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{!logoOnly && <span>{platform}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function exactArrayBuffer(bytes: Uint8Array) {
|
function exactArrayBuffer(bytes: Uint8Array) {
|
||||||
const copy = new Uint8Array(bytes.byteLength);
|
const copy = new Uint8Array(bytes.byteLength);
|
||||||
copy.set(bytes);
|
copy.set(bytes);
|
||||||
@@ -277,6 +328,7 @@ export default function Home() {
|
|||||||
const [adminOrigin, setAdminOrigin] = useState("");
|
const [adminOrigin, setAdminOrigin] = useState("");
|
||||||
const [downloadingImage, setDownloadingImage] = useState<number | null>(null);
|
const [downloadingImage, setDownloadingImage] = useState<number | null>(null);
|
||||||
const [batchDownloading, setBatchDownloading] = useState(false);
|
const [batchDownloading, setBatchDownloading] = useState(false);
|
||||||
|
const [batchWorkbookWorking, setBatchWorkbookWorking] = useState(false);
|
||||||
const [creatorWorking, setCreatorWorking] = useState(false);
|
const [creatorWorking, setCreatorWorking] = useState(false);
|
||||||
const [creatorStage, setCreatorStage] = useState("");
|
const [creatorStage, setCreatorStage] = useState("");
|
||||||
const [selectedForShare, setSelectedForShare] = useState<string[]>([]);
|
const [selectedForShare, setSelectedForShare] = useState<string[]>([]);
|
||||||
@@ -289,6 +341,7 @@ export default function Home() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [noteContentCollapsed, setNoteContentCollapsed] = useState(false);
|
const [noteContentCollapsed, setNoteContentCollapsed] = useState(false);
|
||||||
const submitCardRef = useRef<HTMLFormElement | null>(null);
|
const submitCardRef = useRef<HTMLFormElement | null>(null);
|
||||||
|
const batchWorkbookInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const publishScreenshotPreview = useFilePreview(screenshot);
|
const publishScreenshotPreview = useFilePreview(screenshot);
|
||||||
const creatorScreenshotPreview = useFilePreview(creatorScreenshot);
|
const creatorScreenshotPreview = useFilePreview(creatorScreenshot);
|
||||||
const taskResultPreviews = useMemo(
|
const taskResultPreviews = useMemo(
|
||||||
@@ -504,6 +557,25 @@ export default function Home() {
|
|||||||
return `${adminOrigin}/api/partner-image?${params}`;
|
return `${adminOrigin}/api/partner-image?${params}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const noteVideoUrl = (
|
||||||
|
item: Assignment,
|
||||||
|
videoIndex: number,
|
||||||
|
download = false,
|
||||||
|
) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
distribution: item.id,
|
||||||
|
index: String(videoIndex),
|
||||||
|
kind: "video",
|
||||||
|
});
|
||||||
|
if (download) params.set("download", "1");
|
||||||
|
if (delegationToken) params.set("share", delegationToken);
|
||||||
|
else {
|
||||||
|
params.set("task", taskToken);
|
||||||
|
params.set("claim", claimToken);
|
||||||
|
}
|
||||||
|
return `${adminOrigin}/api/partner-image?${params}`;
|
||||||
|
};
|
||||||
|
|
||||||
const taskResultImageUrl = (item: Assignment, imageIndex: number) => {
|
const taskResultImageUrl = (item: Assignment, imageIndex: number) => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
distribution: item.id,
|
distribution: item.id,
|
||||||
@@ -527,6 +599,11 @@ export default function Home() {
|
|||||||
index: "1",
|
index: "1",
|
||||||
kind,
|
kind,
|
||||||
});
|
});
|
||||||
|
const evidenceKey =
|
||||||
|
kind === "publish"
|
||||||
|
? item.publish_screenshot_key
|
||||||
|
: item.creator_screenshot_key;
|
||||||
|
if (evidenceKey) params.set("v", evidenceKey);
|
||||||
if (delegationToken) params.set("share", delegationToken);
|
if (delegationToken) params.set("share", delegationToken);
|
||||||
else {
|
else {
|
||||||
params.set("task", taskToken);
|
params.set("task", taskToken);
|
||||||
@@ -644,6 +721,112 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const batchWorkbookParams = () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (delegationToken) params.set("share", delegationToken);
|
||||||
|
else {
|
||||||
|
params.set("task", taskToken);
|
||||||
|
params.set("claim", claimToken);
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportBatchWorkbook = async () => {
|
||||||
|
try {
|
||||||
|
setBatchWorkbookWorking(true);
|
||||||
|
const response = await fetch(
|
||||||
|
`${partnerApi("/api/partner-batch-workbook")}?${batchWorkbookParams()}`,
|
||||||
|
{ cache: "no-store" },
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const result = (await response.json()) as { error?: string };
|
||||||
|
throw new Error(result.error || "Excel导出失败");
|
||||||
|
}
|
||||||
|
const disposition = response.headers.get("Content-Disposition") || "";
|
||||||
|
const encodedName = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
||||||
|
const fileName = encodedName
|
||||||
|
? decodeURIComponent(encodedName)
|
||||||
|
: `${payload?.task.name || "领取笔记"}-批量回填.xlsx`;
|
||||||
|
downloadBlob(await response.blob(), fileName);
|
||||||
|
setToast("Excel已导出,填写后从本页面上传即可批量回填");
|
||||||
|
} catch (reason) {
|
||||||
|
setToast(reason instanceof Error ? reason.message : "Excel导出失败");
|
||||||
|
} finally {
|
||||||
|
setBatchWorkbookWorking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const importBatchWorkbook = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
setBatchWorkbookWorking(true);
|
||||||
|
let uploadBody: Blob = file;
|
||||||
|
let compacted = false;
|
||||||
|
if (file.size > PARTNER_BATCH_UPLOAD_MAX_BYTES) {
|
||||||
|
setToast("文件较大,正在保留回填截图并精简原图…");
|
||||||
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
|
const compactedWorkbook = compactPartnerBatchWorkbookForUpload(
|
||||||
|
new Uint8Array(await file.arrayBuffer()),
|
||||||
|
);
|
||||||
|
if (compactedWorkbook.bytes.byteLength > PARTNER_BATCH_UPLOAD_MAX_BYTES) {
|
||||||
|
throw new Error("精简后的回填表仍超过80MB,请重新导出最新版回填表");
|
||||||
|
}
|
||||||
|
uploadBody = new Blob([exactArrayBuffer(compactedWorkbook.bytes)], {
|
||||||
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
});
|
||||||
|
compacted = true;
|
||||||
|
}
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("file", uploadBody, file.name);
|
||||||
|
form.set("taskToken", taskToken);
|
||||||
|
form.set("claimToken", claimToken);
|
||||||
|
form.set("delegationToken", delegationToken);
|
||||||
|
const response = await fetch(partnerApi("/api/partner-batch-workbook"), {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
const contentType = response.headers.get("Content-Type") || "";
|
||||||
|
const result = (contentType.includes("application/json")
|
||||||
|
? await response.json()
|
||||||
|
: {
|
||||||
|
error:
|
||||||
|
response.status === 413
|
||||||
|
? "回填表超过上传限制,请重新导出最新版回填表"
|
||||||
|
: "批量回填服务暂时不可用,请稍后重试",
|
||||||
|
}) as {
|
||||||
|
error?: string;
|
||||||
|
updatedRows?: number;
|
||||||
|
publishedCount?: number;
|
||||||
|
noteScreenshotCount?: number;
|
||||||
|
analysisScreenshotCount?: number;
|
||||||
|
};
|
||||||
|
if (!response.ok) throw new Error(result.error || "批量回填失败");
|
||||||
|
await loadTask(taskToken, claimToken, delegationToken);
|
||||||
|
const details = [
|
||||||
|
result.publishedCount
|
||||||
|
? `${result.publishedCount}篇发布信息`
|
||||||
|
: "",
|
||||||
|
result.noteScreenshotCount
|
||||||
|
? `${result.noteScreenshotCount}张笔记截图`
|
||||||
|
: "",
|
||||||
|
result.analysisScreenshotCount
|
||||||
|
? `${result.analysisScreenshotCount}张数据分析截图`
|
||||||
|
: "",
|
||||||
|
].filter(Boolean);
|
||||||
|
setToast(
|
||||||
|
details.length > 0
|
||||||
|
? `${compacted ? "文件已自动精简," : ""}已更新${details.join("、")}`
|
||||||
|
: `${compacted ? "文件已自动精简," : ""}表格已读取,没有需要更新的数据`,
|
||||||
|
);
|
||||||
|
} catch (reason) {
|
||||||
|
setToast(reason instanceof Error ? reason.message : "批量回填失败");
|
||||||
|
} finally {
|
||||||
|
setBatchWorkbookWorking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const prepareImage = async (item: Assignment, imageIndex: number) => {
|
const prepareImage = async (item: Assignment, imageIndex: number) => {
|
||||||
const response = await fetch(noteImageUrl(item, imageIndex));
|
const response = await fetch(noteImageUrl(item, imageIndex));
|
||||||
if (!response.ok) throw new Error("图片读取失败");
|
if (!response.ok) throw new Error("图片读取失败");
|
||||||
@@ -840,7 +1023,7 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
}, 120);
|
}, 120);
|
||||||
}
|
}
|
||||||
setToast("这篇笔记已回填,不会与其他笔记错配");
|
setToast(PUBLISH_BACKFILL_SUCCESS);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setToast(reason instanceof Error ? reason.message : "回填失败");
|
setToast(reason instanceof Error ? reason.message : "回填失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -987,7 +1170,10 @@ export default function Home() {
|
|||||||
<span>截图任务 {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1}</span>
|
<span>截图任务 {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1}</span>
|
||||||
<span>只需上传结果截图</span>
|
<span>只需上传结果截图</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="document-label">小红书搜索关键词</p>
|
<div className="document-label-row">
|
||||||
|
<p className="document-label">小红书搜索关键词</p>
|
||||||
|
<PlatformBadge platform={payload.task.platform} compact />
|
||||||
|
</div>
|
||||||
<div className="note-title-row">
|
<div className="note-title-row">
|
||||||
<h1>{selected.title}</h1>
|
<h1>{selected.title}</h1>
|
||||||
<button
|
<button
|
||||||
@@ -1107,7 +1293,7 @@ export default function Home() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} />
|
<ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} />
|
||||||
{toast && <div className="toast">{toast}</div>}
|
{toast && <div className={toastClassName(toast)}>{toast}</div>}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1136,7 +1322,9 @@ export default function Home() {
|
|||||||
<span>笔记内容已收起</span>
|
<span>笔记内容已收起</span>
|
||||||
<strong>{selected.title}</strong>
|
<strong>{selected.title}</strong>
|
||||||
<small>
|
<small>
|
||||||
{selected.images.length} 张配图 · 已完成首次回填
|
{selected.videos.length > 0
|
||||||
|
? `${selected.videos.length} 个视频`
|
||||||
|
: `${selected.images.length} 张配图`} · 已完成首次回填
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -1150,6 +1338,7 @@ export default function Home() {
|
|||||||
<div className="note-document-content">
|
<div className="note-document-content">
|
||||||
<div className="note-document-meta">
|
<div className="note-document-meta">
|
||||||
<span>笔记 {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1}</span>
|
<span>笔记 {activeBatch.assignments.findIndex((item) => item.id === selected.id) + 1}</span>
|
||||||
|
<PlatformBadge platform={payload.task.platform} compact />
|
||||||
<span>飞书源行 {selected.source_row ?? "—"}</span>
|
<span>飞书源行 {selected.source_row ?? "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
{selected.publish_url && (
|
{selected.publish_url && (
|
||||||
@@ -1237,6 +1426,38 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
{selected.videos.length > 0 && (
|
||||||
|
<section className="note-videos">
|
||||||
|
<div className="note-images-heading">
|
||||||
|
<div>
|
||||||
|
<p className="document-label">发布视频</p>
|
||||||
|
<span>点击播放,可使用播放器菜单保存原视频</span>
|
||||||
|
</div>
|
||||||
|
<b>{selected.videos.length} 个</b>
|
||||||
|
</div>
|
||||||
|
<div className="note-video-grid">
|
||||||
|
{selected.videos.map((video, index) => (
|
||||||
|
<div className="note-video-card" key={video.index}>
|
||||||
|
<video
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
playsInline
|
||||||
|
src={noteVideoUrl(selected, video.index)}
|
||||||
|
/>
|
||||||
|
<div className="note-image-actions">
|
||||||
|
<span>视频 {index + 1}</span>
|
||||||
|
<a
|
||||||
|
href={noteVideoUrl(selected, video.index, true)}
|
||||||
|
download={`${safeFileBase(selected)}-视频-${index + 1}.mp4`}
|
||||||
|
>
|
||||||
|
下载视频
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -1261,7 +1482,7 @@ export default function Home() {
|
|||||||
inputMode="url"
|
inputMode="url"
|
||||||
value={publishUrl}
|
value={publishUrl}
|
||||||
onChange={(event) => setPublishUrl(event.target.value)}
|
onChange={(event) => setPublishUrl(event.target.value)}
|
||||||
placeholder="可粘贴小红书长链、短链或整段分享文案"
|
placeholder={`可粘贴${payload.task.platform}作品链接或整段分享文案`}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<small className="field-hint">
|
<small className="field-hint">
|
||||||
@@ -1399,7 +1620,6 @@ export default function Home() {
|
|||||||
value={creatorExposure}
|
value={creatorExposure}
|
||||||
onChange={(event) => setCreatorExposure(event.target.value)}
|
onChange={(event) => setCreatorExposure(event.target.value)}
|
||||||
placeholder="填写截图中的曝光量"
|
placeholder="填写截图中的曝光量"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -1413,7 +1633,6 @@ export default function Home() {
|
|||||||
value={creatorViews}
|
value={creatorViews}
|
||||||
onChange={(event) => setCreatorViews(event.target.value)}
|
onChange={(event) => setCreatorViews(event.target.value)}
|
||||||
placeholder="填写截图中的阅读量"
|
placeholder="填写截图中的阅读量"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -1439,7 +1658,7 @@ export default function Home() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} />
|
<ImageLightbox image={previewImage} onClose={() => setPreviewImage(null)} />
|
||||||
{toast && <div className="toast">{toast}</div>}
|
{toast && <div className={toastClassName(toast)}>{toast}</div>}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1478,8 +1697,10 @@ export default function Home() {
|
|||||||
: "合作社转派发布包"}
|
: "合作社转派发布包"}
|
||||||
</p>
|
</p>
|
||||||
<h1>{payload.task.name}</h1>
|
<h1>{payload.task.name}</h1>
|
||||||
<p>
|
<p className="platform-meta-line">
|
||||||
{payload.task.brand} · {formatDate(payload.task.dueAt)}前{isScreenshotTask ? "提交" : "发布"}
|
<span>{payload.task.brand}</span>
|
||||||
|
<PlatformBadge platform={payload.task.platform} compact />
|
||||||
|
<span>{formatDate(payload.task.dueAt)}前{isScreenshotTask ? "提交" : "发布"}</span>
|
||||||
{payload.delegation ? ` · ${payload.delegation.label}` : ""}
|
{payload.delegation ? ` · ${payload.delegation.label}` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1500,7 +1721,7 @@ export default function Home() {
|
|||||||
{isClaimOwner
|
{isClaimOwner
|
||||||
? isScreenshotTask
|
? isScreenshotTask
|
||||||
? "打开一份查看关键词和要求,完成后单独上传截图;也可以转派给底层KOC"
|
? "打开一份查看关键词和要求,完成后单独上传截图;也可以转派给底层KOC"
|
||||||
: "打开一篇,查看内容并单独回填;也可以选择笔记转派给底层KOC"
|
: "可逐篇回填,也可导出Excel填写后批量上传;还可以选择笔记转派给底层KOC"
|
||||||
: isScreenshotTask
|
: isScreenshotTask
|
||||||
? "打开任务查看搜索关键词和要求,完成后逐份上传截图"
|
? "打开任务查看搜索关键词和要求,完成后逐份上传截图"
|
||||||
: "打开一篇查看完整内容,发布后逐篇回填"}
|
: "打开一篇查看完整内容,发布后逐篇回填"}
|
||||||
@@ -1508,6 +1729,31 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="section-actions">
|
<div className="section-actions">
|
||||||
<span>{activeBatch.assignments.length} {isScreenshotTask ? "份" : "篇"}</span>
|
<span>{activeBatch.assignments.length} {isScreenshotTask ? "份" : "篇"}</span>
|
||||||
|
{!isScreenshotTask && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={batchWorkbookWorking}
|
||||||
|
onClick={() => void exportBatchWorkbook()}
|
||||||
|
>
|
||||||
|
{batchWorkbookWorking ? "处理中…" : "导出Excel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={batchWorkbookWorking}
|
||||||
|
onClick={() => batchWorkbookInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
上传回填表
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={batchWorkbookInputRef}
|
||||||
|
className="batch-workbook-input"
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
onChange={(event) => void importBatchWorkbook(event)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{isClaimOwner && (
|
{isClaimOwner && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1609,6 +1855,10 @@ export default function Home() {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
) : item.videos.length > 0 ? (
|
||||||
|
<span className="note-thumb platform-video-thumb">
|
||||||
|
<PlatformBadge platform={payload.task.platform} logoOnly />
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="note-thumb empty">无图</span>
|
<span className="note-thumb empty">无图</span>
|
||||||
)}
|
)}
|
||||||
@@ -1617,7 +1867,11 @@ export default function Home() {
|
|||||||
<p>
|
<p>
|
||||||
{isScreenshotTask
|
{isScreenshotTask
|
||||||
? `搜索关键词 · ${statusLabel(item, payload.task.type)}`
|
? `搜索关键词 · ${statusLabel(item, payload.task.type)}`
|
||||||
: `${item.images.length} 张配图 · 飞书源行 ${item.source_row ?? "—"} · ${statusLabel(item, payload.task.type)}`}
|
: <>
|
||||||
|
<span>{item.videos.length > 0 ? `${item.videos.length} 个视频` : `${item.images.length} 张配图`}</span>
|
||||||
|
<PlatformBadge platform={payload.task.platform} compact />
|
||||||
|
<span>飞书源行 {item.source_row ?? "—"} · {statusLabel(item, payload.task.type)}</span>
|
||||||
|
</>}
|
||||||
{!isScreenshotTask && item.creator_screenshot_key ? " · D7截图已交" : ""}
|
{!isScreenshotTask && item.creator_screenshot_key ? " · D7截图已交" : ""}
|
||||||
{item.delegation_label ? ` · 已转派给 ${item.delegation_label}` : ""}
|
{item.delegation_label ? ` · 已转派给 ${item.delegation_label}` : ""}
|
||||||
</p>
|
</p>
|
||||||
@@ -1711,7 +1965,7 @@ export default function Home() {
|
|||||||
? "请保存当前分享链接;完成任务后通过此链接上传截图即可。"
|
? "请保存当前分享链接;完成任务后通过此链接上传截图即可。"
|
||||||
: "请保存当前分享链接;发布后仍需通过此链接回填第7天截图、曝光量和阅读量。"}
|
: "请保存当前分享链接;发布后仍需通过此链接回填第7天截图、曝光量和阅读量。"}
|
||||||
</div>
|
</div>
|
||||||
{toast && <div className="toast">{toast}</div>}
|
{toast && <div className={toastClassName(toast)}>{toast}</div>}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1724,7 +1978,10 @@ export default function Home() {
|
|||||||
</header>
|
</header>
|
||||||
<section className="task-hero">
|
<section className="task-hero">
|
||||||
<div className="hero-copy">
|
<div className="hero-copy">
|
||||||
<p className="micro">正在招募 · {payload.task.brand}</p>
|
<div className="hero-platform-line">
|
||||||
|
<p className="micro">正在招募 · {payload.task.brand}</p>
|
||||||
|
<PlatformBadge platform={payload.task.platform} />
|
||||||
|
</div>
|
||||||
<h1>{payload.task.name}</h1>
|
<h1>{payload.task.name}</h1>
|
||||||
<p>
|
<p>
|
||||||
{payload.task.type === "screenshot_collect"
|
{payload.task.type === "screenshot_collect"
|
||||||
@@ -1855,7 +2112,7 @@ export default function Home() {
|
|||||||
<div><span>3</span><strong>{payload.task.type === "screenshot_collect" ? "上传截图" : "单篇回填"}</strong><p>{payload.task.type === "screenshot_collect" ? "每份任务与截图一一对应" : "昵称、链接、截图一一对应"}</p></div>
|
<div><span>3</span><strong>{payload.task.type === "screenshot_collect" ? "上传截图" : "单篇回填"}</strong><p>{payload.task.type === "screenshot_collect" ? "每份任务与截图一一对应" : "昵称、链接、截图一一对应"}</p></div>
|
||||||
</section>
|
</section>
|
||||||
<footer>由 KOC LOOP 提供任务与数据安全保障</footer>
|
<footer>由 KOC LOOP 提供任务与数据安全保障</footer>
|
||||||
{toast && <div className="toast">{toast}</div>}
|
{toast && <div className={toastClassName(toast)}>{toast}</div>}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,14 +37,31 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
|
|||||||
assert.match(page, /发布链接/);
|
assert.match(page, /发布链接/);
|
||||||
assert.match(page, /识别发布账号/);
|
assert.match(page, /识别发布账号/);
|
||||||
assert.match(page, /inputMode="url"/);
|
assert.match(page, /inputMode="url"/);
|
||||||
assert.match(page, /长链、短链或整段分享文案/);
|
assert.match(page, /作品链接或整段分享文案/);
|
||||||
assert.doesNotMatch(page, /type="url"/);
|
assert.doesNotMatch(page, /type="url"/);
|
||||||
assert.match(page, /发布截图/);
|
assert.match(page, /发布截图/);
|
||||||
assert.match(page, /发布配图/);
|
assert.match(page, /发布配图/);
|
||||||
assert.match(page, /复制标题/);
|
assert.match(page, /复制标题/);
|
||||||
assert.match(page, /复制文案/);
|
assert.match(page, /复制文案/);
|
||||||
assert.match(page, /下载原图/);
|
assert.match(page, /下载原图/);
|
||||||
|
assert.match(page, /下载视频/);
|
||||||
|
assert.match(page, /download\s*=\s*false/);
|
||||||
|
assert.match(page, /params\.set\("download", "1"\)/);
|
||||||
|
assert.match(page, /视频-\$\{index \+ 1\}\.mp4/);
|
||||||
|
assert.match(page, /function PlatformBadge/);
|
||||||
|
assert.match(page, /platform-logo/);
|
||||||
|
assert.match(page, /logoOnly/);
|
||||||
|
assert.match(page, /platform-video-thumb/);
|
||||||
|
assert.match(styles, /\.platform-logo\.xiaohongshu/);
|
||||||
|
assert.match(styles, /\.platform-logo\.douyin/);
|
||||||
|
assert.match(styles, /\.platform-badge\.logo-only/);
|
||||||
assert.match(page, /批量保存图片/);
|
assert.match(page, /批量保存图片/);
|
||||||
|
assert.match(page, /导出Excel/);
|
||||||
|
assert.match(page, /上传回填表/);
|
||||||
|
assert.match(page, /\/api\/partner-batch-workbook/);
|
||||||
|
assert.match(page, /compactPartnerBatchWorkbookForUpload/);
|
||||||
|
assert.match(page, /Content-Type/);
|
||||||
|
assert.match(page, /response\.status === 413/);
|
||||||
assert.match(page, /navigator\.share/);
|
assert.match(page, /navigator\.share/);
|
||||||
assert.match(page, /zipSync/);
|
assert.match(page, /zipSync/);
|
||||||
assert.match(page, /navigator\.clipboard\.writeText/);
|
assert.match(page, /navigator\.clipboard\.writeText/);
|
||||||
@@ -53,7 +70,9 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
|
|||||||
assert.match(page, /找回领取记录/);
|
assert.match(page, /找回领取记录/);
|
||||||
assert.match(page, /action:\s*"recover"/);
|
assert.match(page, /action:\s*"recover"/);
|
||||||
assert.match(page, /同一任务多次领取会分批展示/);
|
assert.match(page, /同一任务多次领取会分批展示/);
|
||||||
assert.match(page, /这篇笔记已回填,不会与其他笔记错配/);
|
assert.match(page, /这篇笔记的发布记录回填成功啦~/);
|
||||||
|
assert.match(page, /toastClassName\(toast\)/);
|
||||||
|
assert.match(styles, /\.toast\.success/);
|
||||||
assert.match(page, /笔记内容已收起/);
|
assert.match(page, /笔记内容已收起/);
|
||||||
assert.match(page, /展开笔记内容/);
|
assert.match(page, /展开笔记内容/);
|
||||||
assert.match(page, /收起笔记内容/);
|
assert.match(page, /收起笔记内容/);
|
||||||
@@ -61,7 +80,7 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
|
|||||||
assert.match(page, /scrollIntoView/);
|
assert.match(page, /scrollIntoView/);
|
||||||
assert.match(page, /matchMedia\("\(max-width: 620px\)"\)/);
|
assert.match(page, /matchMedia\("\(max-width: 620px\)"\)/);
|
||||||
assert.match(styles, /\.note-document\.mobile-collapsed/);
|
assert.match(styles, /\.note-document\.mobile-collapsed/);
|
||||||
assert.doesNotMatch(page, /批量回填/);
|
assert.match(page, /批量回填/);
|
||||||
assert.doesNotMatch(page, /复制标题和正文/);
|
assert.doesNotMatch(page, /复制标题和正文/);
|
||||||
assert.match(page, /CONFIGURED_ADMIN_ORIGIN/);
|
assert.match(page, /CONFIGURED_ADMIN_ORIGIN/);
|
||||||
assert.match(page, /window\.location\.origin/);
|
assert.match(page, /window\.location\.origin/);
|
||||||
@@ -75,6 +94,7 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
|
|||||||
assert.match(page, /creatorViews/);
|
assert.match(page, /creatorViews/);
|
||||||
assert.match(page, /截图仅用于运营核对,不再自动OCR/);
|
assert.match(page, /截图仅用于运营核对,不再自动OCR/);
|
||||||
assert.match(page, /evidenceImageUrl/);
|
assert.match(page, /evidenceImageUrl/);
|
||||||
|
assert.match(page, /params\.set\("v", evidenceKey\)/);
|
||||||
assert.match(page, /evidenceImageUrl\(selected, "publish"\)/);
|
assert.match(page, /evidenceImageUrl\(selected, "publish"\)/);
|
||||||
assert.match(page, /evidenceImageUrl\(selected, "creator"\)/);
|
assert.match(page, /evidenceImageUrl\(selected, "creator"\)/);
|
||||||
assert.match(page, /ImageLightbox/);
|
assert.match(page, /ImageLightbox/);
|
||||||
@@ -83,6 +103,8 @@ test("keeps claiming minimal and backfill one-to-one", async () => {
|
|||||||
assert.match(page, /creatorScreenshotPreview/);
|
assert.match(page, /creatorScreenshotPreview/);
|
||||||
assert.match(page, /曝光量/);
|
assert.match(page, /曝光量/);
|
||||||
assert.match(page, /阅读量/);
|
assert.match(page, /阅读量/);
|
||||||
|
assert.match(page, /placeholder="填写截图中的曝光量"\s*\/>/);
|
||||||
|
assert.match(page, /placeholder="填写截图中的阅读量"\s*\/>/);
|
||||||
assert.doesNotMatch(page, /recognizeCreatorMetrics/);
|
assert.doesNotMatch(page, /recognizeCreatorMetrics/);
|
||||||
assert.match(page, /note-index \$\{\(isScreenshotTask \? item\.result_submitted_at : item\.publish_url\) \? "done" : ""\}/);
|
assert.match(page, /note-index \$\{\(isScreenshotTask \? item\.result_submitted_at : item\.publish_url\) \? "done" : ""\}/);
|
||||||
assert.match(packageJson, /"fflate":\s*"0\.7\.4"/);
|
assert.match(packageJson, /"fflate":\s*"0\.7\.4"/);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
resolveXhsPublicAccountDetails,
|
resolveXhsPublicAccountDetails,
|
||||||
resolveXhsAccountProfileFromMcp,
|
resolveAccountProfileFromMcp,
|
||||||
resolveXhsProfileDetailsFromMcp,
|
resolveProfileDetailsFromMcp,
|
||||||
type CollectionMcpConfig,
|
type CollectionMcpConfig,
|
||||||
} from "./mcp-collection-client";
|
} from "./mcp-collection-client";
|
||||||
import { hashText } from "./mvp-db";
|
import { hashText } from "./mvp-db";
|
||||||
@@ -11,15 +11,20 @@ type DistributionAccountRow = {
|
|||||||
id: string;
|
id: string;
|
||||||
account_id: string | null;
|
account_id: string | null;
|
||||||
publish_url: string | null;
|
publish_url: string | null;
|
||||||
|
platform: string;
|
||||||
|
claimant_contact: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BackfillRow = DistributionAccountRow & {
|
type BackfillRow = DistributionAccountRow & {
|
||||||
|
resolved_account_id: string | null;
|
||||||
nickname: string | null;
|
nickname: string | null;
|
||||||
platform: string | null;
|
|
||||||
platform_uid: string | null;
|
platform_uid: string | null;
|
||||||
public_account_id: string | null;
|
public_account_id: string | null;
|
||||||
profile_url: string | null;
|
profile_url: string | null;
|
||||||
followers: number | null;
|
followers: number | null;
|
||||||
|
gender: string | null;
|
||||||
|
bio: string | null;
|
||||||
|
tags: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isVerifiedXhsProfileUrl(value: string | null) {
|
function isVerifiedXhsProfileUrl(value: string | null) {
|
||||||
@@ -37,6 +42,23 @@ function isVerifiedXhsProfileUrl(value: string | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isVerifiedDouyinProfileUrl(value: string | null) {
|
||||||
|
if (!value) return false;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
const secUid = decodeURIComponent(url.pathname.match(/^\/user\/([^/?#]+)/)?.[1] ?? "");
|
||||||
|
return (
|
||||||
|
url.protocol === "https:" &&
|
||||||
|
(url.hostname === "douyin.com" ||
|
||||||
|
url.hostname.endsWith(".douyin.com")) &&
|
||||||
|
/^[A-Za-z0-9_-]{20,220}$/.test(secUid) &&
|
||||||
|
!/^\d+$/.test(secUid)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function enrichDistributionAccount(
|
export async function enrichDistributionAccount(
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
distributionId: string,
|
distributionId: string,
|
||||||
@@ -44,27 +66,42 @@ export async function enrichDistributionAccount(
|
|||||||
fallbackNickname: string,
|
fallbackNickname: string,
|
||||||
mcpConfig: CollectionMcpConfig,
|
mcpConfig: CollectionMcpConfig,
|
||||||
) {
|
) {
|
||||||
const profile = await resolveXhsAccountProfileFromMcp(
|
|
||||||
publishUrl,
|
|
||||||
fallbackNickname,
|
|
||||||
mcpConfig,
|
|
||||||
);
|
|
||||||
const current = await db
|
const current = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, account_id, publish_url
|
`SELECT d.id, d.account_id, d.publish_url, t.platform,
|
||||||
FROM distributions
|
cl.claimant_name AS claimant_contact
|
||||||
WHERE id = ?`,
|
FROM distributions d
|
||||||
|
JOIN tasks t ON t.id = d.task_id
|
||||||
|
LEFT JOIN claims cl ON cl.id = d.claim_id
|
||||||
|
WHERE d.id = ?`,
|
||||||
)
|
)
|
||||||
.bind(distributionId)
|
.bind(distributionId)
|
||||||
.first<DistributionAccountRow>();
|
.first<DistributionAccountRow>();
|
||||||
if (!current || current.publish_url !== publishUrl) {
|
if (!current || current.publish_url !== publishUrl) {
|
||||||
return { updated: false, reason: "stale" as const };
|
return { updated: false, reason: "stale" as const };
|
||||||
}
|
}
|
||||||
|
const platform = current.platform === "抖音" ? "抖音" : "小红书";
|
||||||
|
const profile = await resolveAccountProfileFromMcp(
|
||||||
|
publishUrl,
|
||||||
|
fallbackNickname,
|
||||||
|
platform,
|
||||||
|
mcpConfig,
|
||||||
|
);
|
||||||
|
|
||||||
const canonicalAccountId = `account-${hashText(
|
const canonicalAccountId = `account-${hashText(
|
||||||
`小红书:${profile.platformUid}`,
|
`${platform}:${profile.platformUid}`,
|
||||||
)}`;
|
)}`;
|
||||||
if (current.account_id === canonicalAccountId) {
|
const existingAccount = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id FROM accounts
|
||||||
|
WHERE platform = ? AND platform_uid = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
)
|
||||||
|
.bind(platform, profile.platformUid)
|
||||||
|
.first<{ id: string }>();
|
||||||
|
const targetAccountId = existingAccount?.id || canonicalAccountId;
|
||||||
|
const currentContact = (current.claimant_contact || "").trim();
|
||||||
|
if (current.account_id === targetAccountId) {
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE accounts SET
|
`UPDATE accounts SET
|
||||||
@@ -82,6 +119,12 @@ export async function enrichDistributionAccount(
|
|||||||
WHEN ? IS NOT NULL AND (? > 0 OR followers = 0) THEN ?
|
WHEN ? IS NOT NULL AND (? > 0 OR followers = 0) THEN ?
|
||||||
ELSE followers
|
ELSE followers
|
||||||
END,
|
END,
|
||||||
|
gender = CASE WHEN ? != '' THEN ? ELSE gender END,
|
||||||
|
bio = CASE WHEN ? != '' THEN ? ELSE bio END,
|
||||||
|
current_contact = CASE WHEN ? != '' THEN ? ELSE current_contact END,
|
||||||
|
post_count = (
|
||||||
|
SELECT COUNT(*) FROM distributions WHERE account_id = ?
|
||||||
|
),
|
||||||
last_seen_at = CURRENT_TIMESTAMP
|
last_seen_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
)
|
)
|
||||||
@@ -96,12 +139,19 @@ export async function enrichDistributionAccount(
|
|||||||
profile.followers,
|
profile.followers,
|
||||||
profile.followers,
|
profile.followers,
|
||||||
profile.followers,
|
profile.followers,
|
||||||
canonicalAccountId,
|
profile.gender,
|
||||||
|
profile.gender,
|
||||||
|
profile.bio,
|
||||||
|
profile.bio,
|
||||||
|
currentContact,
|
||||||
|
currentContact,
|
||||||
|
targetAccountId,
|
||||||
|
targetAccountId,
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
return {
|
return {
|
||||||
updated: true,
|
updated: true,
|
||||||
accountId: canonicalAccountId,
|
accountId: targetAccountId,
|
||||||
profileUrl: profile.profileUrl,
|
profileUrl: profile.profileUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -111,8 +161,9 @@ export async function enrichDistributionAccount(
|
|||||||
db
|
db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO accounts
|
`INSERT INTO accounts
|
||||||
(id, platform, platform_uid, public_account_id, nickname, profile_url, ip_location, followers, post_count)
|
(id, platform, platform_uid, public_account_id, nickname, profile_url,
|
||||||
VALUES (?, '小红书', ?, ?, ?, ?, ?, ?, 1)
|
ip_location, followers, gender, bio, current_contact, post_count)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||||
ON CONFLICT(platform, platform_uid) DO UPDATE SET
|
ON CONFLICT(platform, platform_uid) DO UPDATE SET
|
||||||
public_account_id = CASE
|
public_account_id = CASE
|
||||||
WHEN excluded.public_account_id != ''
|
WHEN excluded.public_account_id != ''
|
||||||
@@ -131,17 +182,29 @@ export async function enrichDistributionAccount(
|
|||||||
THEN excluded.followers
|
THEN excluded.followers
|
||||||
ELSE accounts.followers
|
ELSE accounts.followers
|
||||||
END,
|
END,
|
||||||
post_count = accounts.post_count + 1,
|
gender = CASE
|
||||||
|
WHEN excluded.gender != '' THEN excluded.gender ELSE accounts.gender END,
|
||||||
|
bio = CASE
|
||||||
|
WHEN excluded.bio != '' THEN excluded.bio ELSE accounts.bio END,
|
||||||
|
current_contact = CASE
|
||||||
|
WHEN excluded.current_contact != ''
|
||||||
|
THEN excluded.current_contact
|
||||||
|
ELSE accounts.current_contact
|
||||||
|
END,
|
||||||
last_seen_at = CURRENT_TIMESTAMP`,
|
last_seen_at = CURRENT_TIMESTAMP`,
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
canonicalAccountId,
|
targetAccountId,
|
||||||
|
platform,
|
||||||
profile.platformUid,
|
profile.platformUid,
|
||||||
profile.redId,
|
profile.redId,
|
||||||
profile.nickname || fallbackNickname,
|
profile.nickname || fallbackNickname,
|
||||||
profile.profileUrl,
|
profile.profileUrl,
|
||||||
profile.ipLocation,
|
profile.ipLocation,
|
||||||
profile.followers ?? 0,
|
profile.followers ?? 0,
|
||||||
|
profile.gender,
|
||||||
|
profile.bio,
|
||||||
|
currentContact,
|
||||||
),
|
),
|
||||||
db
|
db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -150,10 +213,25 @@ export async function enrichDistributionAccount(
|
|||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ? AND publish_url = ?`,
|
WHERE id = ? AND publish_url = ?`,
|
||||||
)
|
)
|
||||||
.bind(canonicalAccountId, distributionId, publishUrl),
|
.bind(targetAccountId, distributionId, publishUrl),
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE accounts SET post_count = (
|
||||||
|
SELECT COUNT(*) FROM distributions WHERE account_id = ?
|
||||||
|
) WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(targetAccountId, targetAccountId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (provisionalAccountId) {
|
if (provisionalAccountId && provisionalAccountId !== targetAccountId) {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE accounts SET post_count = (
|
||||||
|
SELECT COUNT(*) FROM distributions WHERE account_id = ?
|
||||||
|
) WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(provisionalAccountId, provisionalAccountId)
|
||||||
|
.run();
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`DELETE FROM accounts
|
`DELETE FROM accounts
|
||||||
@@ -165,14 +243,14 @@ export async function enrichDistributionAccount(
|
|||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
provisionalAccountId,
|
provisionalAccountId,
|
||||||
canonicalAccountId,
|
targetAccountId,
|
||||||
provisionalAccountId,
|
provisionalAccountId,
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
updated: true,
|
updated: true,
|
||||||
accountId: canonicalAccountId,
|
accountId: targetAccountId,
|
||||||
profileUrl: profile.profileUrl,
|
profileUrl: profile.profileUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -188,15 +266,22 @@ export async function backfillAccountProfiles(
|
|||||||
d.id,
|
d.id,
|
||||||
d.account_id,
|
d.account_id,
|
||||||
d.publish_url,
|
d.publish_url,
|
||||||
|
a.id AS resolved_account_id,
|
||||||
a.nickname,
|
a.nickname,
|
||||||
a.platform,
|
COALESCE(a.platform, t.platform) AS platform,
|
||||||
a.platform_uid,
|
a.platform_uid,
|
||||||
a.public_account_id,
|
a.public_account_id,
|
||||||
a.profile_url,
|
a.profile_url,
|
||||||
a.followers
|
a.followers,
|
||||||
|
a.gender,
|
||||||
|
a.bio,
|
||||||
|
a.tags,
|
||||||
|
cl.claimant_name AS claimant_contact
|
||||||
FROM distributions d
|
FROM distributions d
|
||||||
|
JOIN tasks t ON t.id = d.task_id
|
||||||
LEFT JOIN accounts a ON a.id = d.account_id
|
LEFT JOIN accounts a ON a.id = d.account_id
|
||||||
WHERE d.publish_url IS NOT NULL
|
LEFT JOIN claims cl ON cl.id = d.claim_id
|
||||||
|
WHERE d.publish_url IS NOT NULL
|
||||||
AND d.publish_url != ''
|
AND d.publish_url != ''
|
||||||
ORDER BY d.updated_at DESC
|
ORDER BY d.updated_at DESC
|
||||||
LIMIT 100`,
|
LIMIT 100`,
|
||||||
@@ -205,8 +290,34 @@ export async function backfillAccountProfiles(
|
|||||||
let attempted = 0;
|
let attempted = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
const backfilledAccounts = new Set<string>();
|
||||||
|
|
||||||
for (const row of rows.results) {
|
for (const row of rows.results) {
|
||||||
|
if (
|
||||||
|
row.resolved_account_id &&
|
||||||
|
!backfilledAccounts.has(row.resolved_account_id)
|
||||||
|
) {
|
||||||
|
backfilledAccounts.add(row.resolved_account_id);
|
||||||
|
const claimantContact = row.claimant_contact?.trim() || "";
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE accounts
|
||||||
|
SET current_contact = CASE
|
||||||
|
WHEN ? != '' THEN ? ELSE current_contact
|
||||||
|
END,
|
||||||
|
post_count = (
|
||||||
|
SELECT COUNT(*) FROM distributions WHERE account_id = ?
|
||||||
|
)
|
||||||
|
WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
claimantContact,
|
||||||
|
claimantContact,
|
||||||
|
row.resolved_account_id,
|
||||||
|
row.resolved_account_id,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
if (attempted >= Math.max(1, Math.min(25, limit))) break;
|
if (attempted >= Math.max(1, Math.min(25, limit))) break;
|
||||||
const noteId = (() => {
|
const noteId = (() => {
|
||||||
try {
|
try {
|
||||||
@@ -227,19 +338,31 @@ export async function backfillAccountProfiles(
|
|||||||
row.platform === "小红书" &&
|
row.platform === "小红书" &&
|
||||||
!isDemoAccount &&
|
!isDemoAccount &&
|
||||||
isVerifiedXhsProfileUrl(row.profile_url) &&
|
isVerifiedXhsProfileUrl(row.profile_url) &&
|
||||||
(!row.public_account_id || Number(row.followers ?? 0) === 0)
|
(!row.public_account_id ||
|
||||||
|
Number(row.followers ?? 0) === 0 ||
|
||||||
|
!row.gender ||
|
||||||
|
!row.bio)
|
||||||
) {
|
) {
|
||||||
attempted += 1;
|
attempted += 1;
|
||||||
attemptedThisRow = true;
|
attemptedThisRow = true;
|
||||||
const details = await resolveXhsProfileDetailsFromMcp(
|
const details = await resolveProfileDetailsFromMcp(
|
||||||
row.profile_url ?? "",
|
row.profile_url ?? "",
|
||||||
|
"小红书",
|
||||||
mcpConfig,
|
mcpConfig,
|
||||||
).catch(() =>
|
).catch(async () => ({
|
||||||
resolveXhsPublicAccountDetails(row.profile_url ?? ""),
|
...(await resolveXhsPublicAccountDetails(row.profile_url ?? "")),
|
||||||
);
|
gender: "" as const,
|
||||||
|
bio: "",
|
||||||
|
recentNoteTitles: [] as string[],
|
||||||
|
providerTags: [] as string[],
|
||||||
|
}));
|
||||||
if (
|
if (
|
||||||
row.account_id &&
|
row.account_id &&
|
||||||
(details.redId || details.followers !== null)
|
(details.redId ||
|
||||||
|
details.followers !== null ||
|
||||||
|
details.gender ||
|
||||||
|
details.bio ||
|
||||||
|
details.recentNoteTitles.length > 0)
|
||||||
) {
|
) {
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -256,6 +379,8 @@ export async function backfillAccountProfiles(
|
|||||||
WHEN ? != '' AND ? != '待识别' THEN ?
|
WHEN ? != '' AND ? != '待识别' THEN ?
|
||||||
ELSE ip_location
|
ELSE ip_location
|
||||||
END,
|
END,
|
||||||
|
gender = CASE WHEN ? != '' THEN ? ELSE gender END,
|
||||||
|
bio = CASE WHEN ? != '' THEN ? ELSE bio END,
|
||||||
last_seen_at = CURRENT_TIMESTAMP
|
last_seen_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
)
|
)
|
||||||
@@ -268,6 +393,10 @@ export async function backfillAccountProfiles(
|
|||||||
details.ipLocation ?? "",
|
details.ipLocation ?? "",
|
||||||
details.ipLocation ?? "",
|
details.ipLocation ?? "",
|
||||||
details.ipLocation ?? "",
|
details.ipLocation ?? "",
|
||||||
|
details.gender,
|
||||||
|
details.gender,
|
||||||
|
details.bio,
|
||||||
|
details.bio,
|
||||||
row.account_id,
|
row.account_id,
|
||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
@@ -287,12 +416,17 @@ export async function backfillAccountProfiles(
|
|||||||
}
|
}
|
||||||
const needsEnrichment =
|
const needsEnrichment =
|
||||||
!isDemoAccount &&
|
!isDemoAccount &&
|
||||||
(!row.account_id ||
|
(!row.resolved_account_id ||
|
||||||
(row.platform === "小红书" &&
|
(row.platform === "小红书" &&
|
||||||
!isVerifiedXhsProfileUrl(row.profile_url)) ||
|
!isVerifiedXhsProfileUrl(row.profile_url)) ||
|
||||||
|
(row.platform === "抖音" &&
|
||||||
|
!isVerifiedDouyinProfileUrl(row.profile_url)) ||
|
||||||
(row.platform === "小红书" && !row.public_account_id) ||
|
(row.platform === "小红书" && !row.public_account_id) ||
|
||||||
|
(row.platform === "抖音" && !row.public_account_id) ||
|
||||||
(row.platform === "小红书" &&
|
(row.platform === "小红书" &&
|
||||||
Number(row.followers ?? 0) === 0) ||
|
Number(row.followers ?? 0) === 0) ||
|
||||||
|
(row.platform === "抖音" &&
|
||||||
|
Number(row.followers ?? 0) === 0) ||
|
||||||
row.platform_uid?.startsWith("pending-") ||
|
row.platform_uid?.startsWith("pending-") ||
|
||||||
Boolean(noteId && row.platform_uid === noteId));
|
Boolean(noteId && row.platform_uid === noteId));
|
||||||
if (!needsEnrichment || !row.publish_url) {
|
if (!needsEnrichment || !row.publish_url) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
collectXhsMetricsFromMcp,
|
collectMetricsFromMcp,
|
||||||
type CollectionMcpConfig,
|
type CollectionMcpConfig,
|
||||||
} from "./mcp-collection-client";
|
} from "./mcp-collection-client";
|
||||||
import { uid } from "./mvp-db";
|
import { uid } from "./mvp-db";
|
||||||
@@ -10,6 +10,7 @@ type DistributionForCollection = {
|
|||||||
task_id: string;
|
task_id: string;
|
||||||
publish_url: string | null;
|
publish_url: string | null;
|
||||||
ocr_status: string;
|
ocr_status: string;
|
||||||
|
platform: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ScheduledTask = {
|
type ScheduledTask = {
|
||||||
@@ -165,11 +166,16 @@ export async function collectDistributionMetrics(
|
|||||||
mcpConfig: CollectionMcpConfig,
|
mcpConfig: CollectionMcpConfig,
|
||||||
) {
|
) {
|
||||||
const current = await db
|
const current = await db
|
||||||
.prepare("SELECT * FROM distributions WHERE id = ?")
|
.prepare(
|
||||||
|
`SELECT d.*, t.platform
|
||||||
|
FROM distributions d
|
||||||
|
JOIN tasks t ON t.id = d.task_id
|
||||||
|
WHERE d.id = ?`,
|
||||||
|
)
|
||||||
.bind(distributionId)
|
.bind(distributionId)
|
||||||
.first<DistributionForCollection>();
|
.first<DistributionForCollection>();
|
||||||
if (!current) throw new Error("分发记录不存在");
|
if (!current) throw new Error("分发记录不存在");
|
||||||
if (!current.publish_url) throw new Error("笔记尚未回填发布链接");
|
if (!current.publish_url) throw new Error("作品尚未回填发布链接");
|
||||||
|
|
||||||
const scheduledAt = `${scheduledDate}T09:00:00+08:00`;
|
const scheduledAt = `${scheduledDate}T09:00:00+08:00`;
|
||||||
const runId = uid("run");
|
const runId = uid("run");
|
||||||
@@ -203,7 +209,12 @@ export async function collectDistributionMetrics(
|
|||||||
.bind(distributionId, scheduledDate)
|
.bind(distributionId, scheduledDate)
|
||||||
.first<{ id: string; status: string }>();
|
.first<{ id: string; status: string }>();
|
||||||
if (!run) throw new Error("采集任务创建失败");
|
if (!run) throw new Error("采集任务创建失败");
|
||||||
if (run.status === "success") return { skipped: true };
|
// Scheduled jobs should remain idempotent, but an operator clicking
|
||||||
|
// “立即采集” is explicitly asking for a fresh snapshot. Reusing the same
|
||||||
|
// daily run lets us correct stale or previously mis-mapped platform data.
|
||||||
|
if (run.status === "success" && source !== "manual") {
|
||||||
|
return { skipped: true };
|
||||||
|
}
|
||||||
|
|
||||||
const collectingDescription =
|
const collectingDescription =
|
||||||
source === "automatic"
|
source === "automatic"
|
||||||
@@ -235,8 +246,12 @@ export async function collectDistributionMetrics(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { likes, comments, collects } =
|
const { likes, comments, collects, shares } =
|
||||||
await collectXhsMetricsFromMcp(current.publish_url, mcpConfig);
|
await collectMetricsFromMcp(
|
||||||
|
current.publish_url,
|
||||||
|
current.platform === "抖音" ? "抖音" : "小红书",
|
||||||
|
mcpConfig,
|
||||||
|
);
|
||||||
const dayWeight = scheduleDay ?? 1;
|
const dayWeight = scheduleDay ?? 1;
|
||||||
const successDescription =
|
const successDescription =
|
||||||
source === "automatic"
|
source === "automatic"
|
||||||
@@ -253,6 +268,7 @@ export async function collectDistributionMetrics(
|
|||||||
likes = ?,
|
likes = ?,
|
||||||
comments = ?,
|
comments = ?,
|
||||||
collects = ?,
|
collects = ?,
|
||||||
|
shares = ?,
|
||||||
status_description = ?,
|
status_description = ?,
|
||||||
completed_at = CURRENT_TIMESTAMP
|
completed_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
@@ -261,6 +277,7 @@ export async function collectDistributionMetrics(
|
|||||||
likes,
|
likes,
|
||||||
comments,
|
comments,
|
||||||
collects,
|
collects,
|
||||||
|
shares,
|
||||||
successDescription,
|
successDescription,
|
||||||
run.id,
|
run.id,
|
||||||
),
|
),
|
||||||
@@ -270,6 +287,7 @@ export async function collectDistributionMetrics(
|
|||||||
SET latest_likes = ?,
|
SET latest_likes = ?,
|
||||||
latest_comments = ?,
|
latest_comments = ?,
|
||||||
latest_collects = ?,
|
latest_collects = ?,
|
||||||
|
latest_shares = ?,
|
||||||
collection_status = 'success',
|
collection_status = 'success',
|
||||||
collection_status_description = ?,
|
collection_status_description = ?,
|
||||||
collection_updated_at = CURRENT_TIMESTAMP,
|
collection_updated_at = CURRENT_TIMESTAMP,
|
||||||
@@ -285,12 +303,13 @@ export async function collectDistributionMetrics(
|
|||||||
likes,
|
likes,
|
||||||
comments,
|
comments,
|
||||||
collects,
|
collects,
|
||||||
|
shares,
|
||||||
successDescription,
|
successDescription,
|
||||||
scheduleDay,
|
scheduleDay,
|
||||||
distributionId,
|
distributionId,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
return { skipped: false, likes, comments, collects };
|
return { skipped: false, likes, comments, collects, shares };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "公开数据采集失败";
|
error instanceof Error ? error.message : "公开数据采集失败";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const FEISHU_API_ORIGIN = "https://open.feishu.cn";
|
|||||||
const MAX_SHEET_ROWS = 5_000;
|
const MAX_SHEET_ROWS = 5_000;
|
||||||
const MAX_SHEET_COLUMNS = 100;
|
const MAX_SHEET_COLUMNS = 100;
|
||||||
const MAX_CONTENT_ROWS = 1_000;
|
const MAX_CONTENT_ROWS = 1_000;
|
||||||
const MAX_MEDIA_BYTES = 20_000_000;
|
const DEFAULT_MAX_MEDIA_BYTES = 200_000_000;
|
||||||
|
|
||||||
export type FeishuBindings = {
|
export type FeishuBindings = {
|
||||||
FEISHU_APP_ID?: string;
|
FEISHU_APP_ID?: string;
|
||||||
@@ -16,11 +16,20 @@ export type FeishuSourceImage = {
|
|||||||
height: number | null;
|
height: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FeishuSourceVideo = {
|
||||||
|
index: number;
|
||||||
|
fileToken: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type FeishuSourceRow = {
|
export type FeishuSourceRow = {
|
||||||
sourceRow: number;
|
sourceRow: number;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
images: FeishuSourceImage[];
|
images: FeishuSourceImage[];
|
||||||
|
videos: FeishuSourceVideo[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FeishuSource = {
|
export type FeishuSource = {
|
||||||
@@ -106,12 +115,47 @@ function cellText(value: unknown): string {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
if (!isRecord(value) || value.type === "embed-image") return "";
|
if (
|
||||||
|
!isRecord(value) ||
|
||||||
|
value.type === "embed-image" ||
|
||||||
|
value.type === "attachment"
|
||||||
|
) return "";
|
||||||
if (typeof value.text === "string") return value.text.trim();
|
if (typeof value.text === "string") return value.text.trim();
|
||||||
if (typeof value.value === "string") return value.value.trim();
|
if (typeof value.value === "string") return value.value.trim();
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractVideos(value: unknown, output: FeishuSourceVideo[]) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) extractVideos(item, output);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isRecord(value)) return;
|
||||||
|
const fileToken = bindingValue(value.fileToken ?? value.file_token);
|
||||||
|
const mimeType = bindingValue(value.mimeType ?? value.mime_type);
|
||||||
|
const name = bindingValue(value.text ?? value.name ?? value.file_name);
|
||||||
|
const isVideo =
|
||||||
|
value.type === "attachment" &&
|
||||||
|
(mimeType.startsWith("video/") || /\.(?:mp4|mov|m4v|webm)$/i.test(name));
|
||||||
|
if (isVideo && fileToken) {
|
||||||
|
output.push({
|
||||||
|
index: 0,
|
||||||
|
fileToken,
|
||||||
|
name: name || "视频",
|
||||||
|
mimeType: mimeType || "video/mp4",
|
||||||
|
size:
|
||||||
|
typeof value.size === "number" && Number.isFinite(value.size)
|
||||||
|
? value.size
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const child of Object.values(value)) {
|
||||||
|
if (child !== value.fileToken && child !== value.file_token) {
|
||||||
|
extractVideos(child, output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function extractImages(value: unknown, output: FeishuSourceImage[]) {
|
function extractImages(value: unknown, output: FeishuSourceImage[]) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
for (const item of value) extractImages(item, output);
|
for (const item of value) extractImages(item, output);
|
||||||
@@ -167,6 +211,7 @@ function findHeader(values: unknown[][]) {
|
|||||||
titleIndex: number;
|
titleIndex: number;
|
||||||
bodyIndex: number;
|
bodyIndex: number;
|
||||||
tagsIndex: number;
|
tagsIndex: number;
|
||||||
|
videoIndex: number;
|
||||||
score: number;
|
score: number;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -185,9 +230,13 @@ function findHeader(values: unknown[][]) {
|
|||||||
const tagsIndex = headers.findIndex((header) =>
|
const tagsIndex = headers.findIndex((header) =>
|
||||||
headerMatches(header, [/标签/, /话题/, /^tags?$/]),
|
headerMatches(header, [/标签/, /话题/, /^tags?$/]),
|
||||||
);
|
);
|
||||||
|
const videoIndex = headers.findIndex((header) =>
|
||||||
|
headerMatches(header, [/^视频\d*$/, /视频文件/, /视频素材/]),
|
||||||
|
);
|
||||||
const score =
|
const score =
|
||||||
(titleIndex >= 0 ? 5 : 0) +
|
(titleIndex >= 0 ? 5 : 0) +
|
||||||
(bodyIndex >= 0 ? 5 : 0) +
|
(bodyIndex >= 0 ? 5 : 0) +
|
||||||
|
(videoIndex >= 0 ? 2 : 0) +
|
||||||
(idIndex >= 0 ? 1 : 0) +
|
(idIndex >= 0 ? 1 : 0) +
|
||||||
(tagsIndex >= 0 ? 1 : 0);
|
(tagsIndex >= 0 ? 1 : 0);
|
||||||
if (!best || score > best.score) {
|
if (!best || score > best.score) {
|
||||||
@@ -197,6 +246,7 @@ function findHeader(values: unknown[][]) {
|
|||||||
titleIndex,
|
titleIndex,
|
||||||
bodyIndex,
|
bodyIndex,
|
||||||
tagsIndex,
|
tagsIndex,
|
||||||
|
videoIndex,
|
||||||
score,
|
score,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -217,6 +267,7 @@ function parseRows(values: unknown[][]) {
|
|||||||
const usedSourceRows = new Set<number>();
|
const usedSourceRows = new Set<number>();
|
||||||
const rows: FeishuSourceRow[] = [];
|
const rows: FeishuSourceRow[] = [];
|
||||||
let maxImageCount = 0;
|
let maxImageCount = 0;
|
||||||
|
let maxVideoCount = 0;
|
||||||
|
|
||||||
for (
|
for (
|
||||||
let rowIndex = header.rowIndex + 1;
|
let rowIndex = header.rowIndex + 1;
|
||||||
@@ -253,7 +304,20 @@ function parseRows(values: unknown[][]) {
|
|||||||
})
|
})
|
||||||
.map((image, imageIndex) => ({ ...image, index: imageIndex + 1 }));
|
.map((image, imageIndex) => ({ ...image, index: imageIndex + 1 }));
|
||||||
maxImageCount = Math.max(maxImageCount, images.length);
|
maxImageCount = Math.max(maxImageCount, images.length);
|
||||||
rows.push({ sourceRow, title, body, images });
|
const collectedVideos: FeishuSourceVideo[] = [];
|
||||||
|
if (header.videoIndex >= 0) {
|
||||||
|
extractVideos(row[header.videoIndex], collectedVideos);
|
||||||
|
}
|
||||||
|
const seenVideoTokens = new Set<string>();
|
||||||
|
const videos = collectedVideos
|
||||||
|
.filter((video) => {
|
||||||
|
if (seenVideoTokens.has(video.fileToken)) return false;
|
||||||
|
seenVideoTokens.add(video.fileToken);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((video, videoIndex) => ({ ...video, index: videoIndex + 1 }));
|
||||||
|
maxVideoCount = Math.max(maxVideoCount, videos.length);
|
||||||
|
rows.push({ sourceRow, title, body, images, videos });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@@ -266,6 +330,7 @@ function parseRows(values: unknown[][]) {
|
|||||||
header.tagsIndex >= 0 ? cellText(headerRow[header.tagsIndex]) : "",
|
header.tagsIndex >= 0 ? cellText(headerRow[header.tagsIndex]) : "",
|
||||||
cellText(headerRow[header.bodyIndex]) || "正文",
|
cellText(headerRow[header.bodyIndex]) || "正文",
|
||||||
...Array.from({ length: maxImageCount }, (_, index) => `图片${index + 1}`),
|
...Array.from({ length: maxImageCount }, (_, index) => `图片${index + 1}`),
|
||||||
|
...Array.from({ length: maxVideoCount }, (_, index) => `视频${index + 1}`),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -515,34 +580,37 @@ export async function downloadFeishuMedia(
|
|||||||
fileToken: string,
|
fileToken: string,
|
||||||
bindings: FeishuBindings,
|
bindings: FeishuBindings,
|
||||||
fetchImpl: FetchLike = fetch,
|
fetchImpl: FetchLike = fetch,
|
||||||
|
options: { maxBytes?: number; label?: string; timeoutMs?: number } = {},
|
||||||
) {
|
) {
|
||||||
|
const maxBytes = Math.max(1, options.maxBytes ?? DEFAULT_MAX_MEDIA_BYTES);
|
||||||
|
const label = bindingValue(options.label) || "素材";
|
||||||
const normalizedToken = bindingValue(fileToken);
|
const normalizedToken = bindingValue(fileToken);
|
||||||
if (!/^[A-Za-z0-9_-]{8,240}$/.test(normalizedToken)) {
|
if (!/^[A-Za-z0-9_-]{8,240}$/.test(normalizedToken)) {
|
||||||
throw new FeishuSourceError("飞书图片标识无效", 400);
|
throw new FeishuSourceError(`飞书${label}标识无效`, 400);
|
||||||
}
|
}
|
||||||
const token = await accessToken(bindings, fetchImpl);
|
const token = await accessToken(bindings, fetchImpl);
|
||||||
const response = await fetchImpl(
|
const response = await fetchImpl(
|
||||||
`${FEISHU_API_ORIGIN}/open-apis/drive/v1/medias/${encodeURIComponent(normalizedToken)}/download`,
|
`${FEISHU_API_ORIGIN}/open-apis/drive/v1/medias/${encodeURIComponent(normalizedToken)}/download`,
|
||||||
{
|
{
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
signal: AbortSignal.timeout(20_000),
|
signal: AbortSignal.timeout(options.timeoutMs ?? 120_000),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const declaredSize = Number(response.headers.get("content-length"));
|
const declaredSize = Number(response.headers.get("content-length"));
|
||||||
if (Number.isFinite(declaredSize) && declaredSize > MAX_MEDIA_BYTES) {
|
if (Number.isFinite(declaredSize) && declaredSize > maxBytes) {
|
||||||
throw new FeishuSourceError("飞书图片超过20MB,无法同步", 413);
|
throw new FeishuSourceError(`飞书${label}文件过大,无法同步`, 413);
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new FeishuSourceError(
|
throw new FeishuSourceError(
|
||||||
response.status === 403
|
response.status === 403
|
||||||
? "飞书应用没有这张图片的下载权限"
|
? `飞书应用没有这个${label}的下载权限`
|
||||||
: `下载飞书图片失败(HTTP ${response.status})`,
|
: `下载飞书${label}失败(HTTP ${response.status})`,
|
||||||
response.status === 403 ? 403 : 502,
|
response.status === 403 ? 403 : 502,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const bytes = await response.arrayBuffer();
|
const bytes = await response.arrayBuffer();
|
||||||
if (bytes.byteLength > MAX_MEDIA_BYTES) {
|
if (bytes.byteLength > maxBytes) {
|
||||||
throw new FeishuSourceError("飞书图片超过20MB,无法同步", 413);
|
throw new FeishuSourceError(`飞书${label}文件过大,无法同步`, 413);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
bytes,
|
bytes,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type XhsPublicMetrics = {
|
|||||||
likes: number;
|
likes: number;
|
||||||
comments: number;
|
comments: number;
|
||||||
collects: number;
|
collects: number;
|
||||||
|
shares: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type XhsAccountProfile = {
|
export type XhsAccountProfile = {
|
||||||
@@ -26,6 +27,10 @@ export type XhsAccountProfile = {
|
|||||||
redId: string;
|
redId: string;
|
||||||
ipLocation: string;
|
ipLocation: string;
|
||||||
followers: number | null;
|
followers: number | null;
|
||||||
|
gender: "" | "男" | "女";
|
||||||
|
bio: string;
|
||||||
|
recentNoteTitles: string[];
|
||||||
|
providerTags: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type JsonRpcEnvelope = {
|
type JsonRpcEnvelope = {
|
||||||
@@ -341,16 +346,118 @@ function metricsFromToolResult(result: ToolResult): XhsPublicMetrics {
|
|||||||
}
|
}
|
||||||
if (!data) throw new Error("采集结果缺少互动数据");
|
if (!data) throw new Error("采集结果缺少互动数据");
|
||||||
|
|
||||||
|
const count = (value: unknown, label: string) =>
|
||||||
|
value === null || value === undefined || value === ""
|
||||||
|
? 0
|
||||||
|
: metricValue(value, label);
|
||||||
return {
|
return {
|
||||||
likes: metricValue(data.likes, "点赞数"),
|
likes: count(
|
||||||
comments: metricValue(data.comments, "评论数"),
|
data.likes ??
|
||||||
collects: metricValue(
|
data.liked_count ??
|
||||||
data.collects ?? data.favorites ?? data.favourites,
|
data.likedCount ??
|
||||||
|
data.like_count ??
|
||||||
|
data.likeCount ??
|
||||||
|
data.digg_count ??
|
||||||
|
data.diggCount,
|
||||||
|
"点赞数",
|
||||||
|
),
|
||||||
|
comments: count(
|
||||||
|
data.comments ?? data.comment_count ?? data.commentCount,
|
||||||
|
"评论数",
|
||||||
|
),
|
||||||
|
collects: count(
|
||||||
|
data.collects ??
|
||||||
|
data.collected_count ??
|
||||||
|
data.collectedCount ??
|
||||||
|
data.favorites ??
|
||||||
|
data.favourites ??
|
||||||
|
data.collect_count ??
|
||||||
|
data.collectCount,
|
||||||
"收藏数",
|
"收藏数",
|
||||||
),
|
),
|
||||||
|
shares: count(
|
||||||
|
data.shares ??
|
||||||
|
data.share_count ??
|
||||||
|
data.shareCount ??
|
||||||
|
data.forwards ??
|
||||||
|
data.forward_count ??
|
||||||
|
data.forwardCount,
|
||||||
|
"转发数",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function usableDouyinSecUid(value: unknown) {
|
||||||
|
const candidate = stringValue(value);
|
||||||
|
return candidate && !/^\d+$/.test(candidate) && /^[A-Za-z0-9_-]{20,220}$/.test(candidate)
|
||||||
|
? candidate
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifiedDouyinProfileUrl(value: unknown) {
|
||||||
|
const candidate = stringValue(value);
|
||||||
|
if (!candidate) return "";
|
||||||
|
try {
|
||||||
|
const parsed = new URL(candidate);
|
||||||
|
const secUid = decodeURIComponent(parsed.pathname.match(/^\/user\/([^/?#]+)/)?.[1] ?? "");
|
||||||
|
if (
|
||||||
|
parsed.protocol === "https:" &&
|
||||||
|
(parsed.hostname === "douyin.com" || parsed.hostname.endsWith(".douyin.com")) &&
|
||||||
|
usableDouyinSecUid(secUid)
|
||||||
|
) {
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The public redirect fallback below can still recover the profile URL.
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function douyinProfileFromPublicRedirect(
|
||||||
|
publishUrl: string,
|
||||||
|
fetchImpl: typeof fetch,
|
||||||
|
timeoutMs: number,
|
||||||
|
) {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(publishUrl);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
parsed.protocol !== "https:" ||
|
||||||
|
!(parsed.hostname === "douyin.com" || parsed.hostname.endsWith(".douyin.com"))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(parsed.toString(), {
|
||||||
|
method: "GET",
|
||||||
|
redirect: "manual",
|
||||||
|
signal: AbortSignal.timeout(Math.min(timeoutMs, 15_000)),
|
||||||
|
headers: {
|
||||||
|
"user-agent":
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const location = response.headers.get("location");
|
||||||
|
if (!location) return null;
|
||||||
|
const redirectUrl = new URL(location, parsed);
|
||||||
|
const secUid = usableDouyinSecUid(
|
||||||
|
redirectUrl.searchParams.get("sec_uid") ??
|
||||||
|
redirectUrl.searchParams.get("sec_user_id"),
|
||||||
|
);
|
||||||
|
return secUid
|
||||||
|
? {
|
||||||
|
platformUid: secUid,
|
||||||
|
profileUrl: `https://www.douyin.com/user/${encodeURIComponent(secUid)}`,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function successfulToolData(result: ToolResult, fallbackMessage: string) {
|
function successfulToolData(result: ToolResult, fallbackMessage: string) {
|
||||||
const root = asRecord(result.payload);
|
const root = asRecord(result.payload);
|
||||||
const response = asRecord(root?.response) ?? root;
|
const response = asRecord(root?.response) ?? root;
|
||||||
@@ -446,6 +553,64 @@ function findValueByKeys(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function profileGender(value: unknown): "" | "男" | "女" {
|
||||||
|
if (value === 1) return "男";
|
||||||
|
if (value === 2) return "女";
|
||||||
|
const normalized = String(value ?? "").trim().toLocaleLowerCase("zh-CN");
|
||||||
|
if (["男", "男性", "male", "m", "1"].includes(normalized)) return "男";
|
||||||
|
if (["女", "女性", "female", "f", "2"].includes(normalized)) return "女";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentNoteTitlesFromPayload(value: unknown) {
|
||||||
|
const titles: string[] = [];
|
||||||
|
const visit = (current: unknown, depth = 0) => {
|
||||||
|
if (depth > 12 || titles.length >= 20) return;
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
current.forEach((item) => visit(item, depth + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const record = asRecord(current);
|
||||||
|
if (!record) return;
|
||||||
|
const title = stringValue(record.title ?? record.note_title ?? record.noteTitle);
|
||||||
|
if (
|
||||||
|
title &&
|
||||||
|
(record.note_id || record.noteId || record.url || record.cover) &&
|
||||||
|
!titles.includes(title)
|
||||||
|
) {
|
||||||
|
titles.push(title);
|
||||||
|
}
|
||||||
|
Object.values(record).forEach((child) => visit(child, depth + 1));
|
||||||
|
};
|
||||||
|
visit(value);
|
||||||
|
return titles;
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerTagsFromUser(value: unknown) {
|
||||||
|
const user = findRecord(value, (record) =>
|
||||||
|
Boolean(
|
||||||
|
record.gender !== undefined ||
|
||||||
|
record.desc !== undefined ||
|
||||||
|
record.signature !== undefined ||
|
||||||
|
record.fansCount !== undefined ||
|
||||||
|
record.fans_count !== undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const raw = user?.tags;
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
raw
|
||||||
|
.map((item) =>
|
||||||
|
typeof item === "string"
|
||||||
|
? item.trim()
|
||||||
|
: stringValue(asRecord(item)?.name ?? asRecord(item)?.title),
|
||||||
|
)
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
].slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
const FOLLOWER_KEYS = new Set([
|
const FOLLOWER_KEYS = new Set([
|
||||||
"fans",
|
"fans",
|
||||||
"fans_count",
|
"fans_count",
|
||||||
@@ -529,10 +694,13 @@ async function xhsNoteIdFromShortLink(
|
|||||||
) {
|
) {
|
||||||
return { noteId: "", profile: null };
|
return { noteId: "", profile: null };
|
||||||
}
|
}
|
||||||
if (
|
const isShortLink =
|
||||||
url.hostname !== "xhslink.cn" &&
|
url.hostname === "xhslink.cn" ||
|
||||||
!url.hostname.endsWith(".xhslink.cn")
|
url.hostname.endsWith(".xhslink.cn");
|
||||||
) {
|
const isXhsPage =
|
||||||
|
url.hostname === "xiaohongshu.com" ||
|
||||||
|
url.hostname.endsWith(".xiaohongshu.com");
|
||||||
|
if (!isShortLink && !isXhsPage) {
|
||||||
return { noteId: "", profile: null };
|
return { noteId: "", profile: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,6 +776,10 @@ function accountProfileFromPublicPage(
|
|||||||
redId,
|
redId,
|
||||||
ipLocation: "待识别",
|
ipLocation: "待识别",
|
||||||
followers: null,
|
followers: null,
|
||||||
|
gender: "",
|
||||||
|
bio: "",
|
||||||
|
recentNoteTitles: [],
|
||||||
|
providerTags: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,15 +870,45 @@ function profileDetailsFromToolResult(result: ToolResult) {
|
|||||||
const redId =
|
const redId =
|
||||||
findStringByKey(payload, "red_id") ||
|
findStringByKey(payload, "red_id") ||
|
||||||
findStringByKey(payload, "redId") ||
|
findStringByKey(payload, "redId") ||
|
||||||
|
findStringByKey(payload, "unique_id") ||
|
||||||
|
findStringByKey(payload, "uniqueId") ||
|
||||||
|
findStringByKey(payload, "short_id") ||
|
||||||
|
findStringByKey(payload, "shortId") ||
|
||||||
|
findStringByKey(payload, "douyin_id") ||
|
||||||
findStringByKey(payload, "userId") ||
|
findStringByKey(payload, "userId") ||
|
||||||
findStringByKey(payload, "user_id");
|
findStringByKey(payload, "user_id");
|
||||||
const ipLocation =
|
const ipLocation =
|
||||||
findStringByKey(payload, "ip_location") ||
|
findStringByKey(payload, "ip_location") ||
|
||||||
findStringByKey(payload, "ipLocation");
|
findStringByKey(payload, "ipLocation");
|
||||||
if (followers === null && !nickname && !redId && !ipLocation) {
|
const gender = profileGender(findValueByKeys(payload, new Set(["gender", "sex"])));
|
||||||
|
const bio =
|
||||||
|
findStringByKey(payload, "desc") ||
|
||||||
|
findStringByKey(payload, "description") ||
|
||||||
|
findStringByKey(payload, "signature") ||
|
||||||
|
findStringByKey(payload, "bio");
|
||||||
|
const recentNoteTitles = recentNoteTitlesFromPayload(payload);
|
||||||
|
const providerTags = providerTagsFromUser(payload);
|
||||||
|
if (
|
||||||
|
followers === null &&
|
||||||
|
!nickname &&
|
||||||
|
!redId &&
|
||||||
|
!ipLocation &&
|
||||||
|
!gender &&
|
||||||
|
!bio &&
|
||||||
|
recentNoteTitles.length === 0
|
||||||
|
) {
|
||||||
throw new Error("账号主页采集结果缺少可用字段");
|
throw new Error("账号主页采集结果缺少可用字段");
|
||||||
}
|
}
|
||||||
return { nickname, followers, redId, ipLocation };
|
return {
|
||||||
|
nickname,
|
||||||
|
followers,
|
||||||
|
redId,
|
||||||
|
ipLocation,
|
||||||
|
gender,
|
||||||
|
bio,
|
||||||
|
recentNoteTitles,
|
||||||
|
providerTags,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function accountProfileFromToolResult(
|
function accountProfileFromToolResult(
|
||||||
@@ -718,14 +920,18 @@ function accountProfileFromToolResult(
|
|||||||
data,
|
data,
|
||||||
(record) =>
|
(record) =>
|
||||||
Boolean(
|
Boolean(
|
||||||
stringValue(record.user_id ?? record.userid) &&
|
stringValue(record.user_id ?? record.userid ?? record.userId) &&
|
||||||
(stringValue(record.profile_url) ||
|
(stringValue(record.profile_url) ||
|
||||||
stringValue(record.nickname ?? record.name)),
|
stringValue(record.nickname ?? record.name)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (!user) throw new Error("账号主页识别结果缺少作者信息");
|
if (!user) throw new Error("账号主页识别结果缺少作者信息");
|
||||||
const platformUid = stringValue(user.user_id ?? user.userid);
|
const platformUid = stringValue(
|
||||||
const candidateProfileUrl = stringValue(user.profile_url);
|
user.user_id ?? user.userid ?? user.userId,
|
||||||
|
);
|
||||||
|
const candidateProfileUrl = stringValue(
|
||||||
|
user.profile_url ?? user.profileUrl,
|
||||||
|
);
|
||||||
let profileUrl = "";
|
let profileUrl = "";
|
||||||
if (candidateProfileUrl) {
|
if (candidateProfileUrl) {
|
||||||
try {
|
try {
|
||||||
@@ -755,6 +961,10 @@ function accountProfileFromToolResult(
|
|||||||
redId: stringValue(user.red_id),
|
redId: stringValue(user.red_id),
|
||||||
ipLocation: findStringByKey(data, "ip_location") || "待识别",
|
ipLocation: findStringByKey(data, "ip_location") || "待识别",
|
||||||
followers: followerCountFromPayload(data),
|
followers: followerCountFromPayload(data),
|
||||||
|
gender: "",
|
||||||
|
bio: "",
|
||||||
|
recentNoteTitles: [],
|
||||||
|
providerTags: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -771,18 +981,7 @@ async function completeAccountProfile(
|
|||||||
"parse_xhs_user_summary",
|
"parse_xhs_user_summary",
|
||||||
{ url: profile.profileUrl, use_proxy: true },
|
{ url: profile.profileUrl, use_proxy: true },
|
||||||
],
|
],
|
||||||
[
|
|
||||||
"fetch_user_detail",
|
|
||||||
{ link: profile.profileUrl, plant: "xhs" },
|
|
||||||
],
|
|
||||||
] as const) {
|
] as const) {
|
||||||
if (
|
|
||||||
completed.followers !== null &&
|
|
||||||
completed.redId &&
|
|
||||||
completed.ipLocation !== "待识别"
|
|
||||||
) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const result = await callMcpTool(
|
const result = await callMcpTool(
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
@@ -801,6 +1000,16 @@ async function completeAccountProfile(
|
|||||||
details.ipLocation && details.ipLocation !== "待识别"
|
details.ipLocation && details.ipLocation !== "待识别"
|
||||||
? details.ipLocation
|
? details.ipLocation
|
||||||
: completed.ipLocation,
|
: completed.ipLocation,
|
||||||
|
gender: details.gender || completed.gender,
|
||||||
|
bio: details.bio || completed.bio,
|
||||||
|
recentNoteTitles:
|
||||||
|
details.recentNoteTitles.length > 0
|
||||||
|
? details.recentNoteTitles
|
||||||
|
: completed.recentNoteTitles,
|
||||||
|
providerTags:
|
||||||
|
details.providerTags.length > 0
|
||||||
|
? details.providerTags
|
||||||
|
: completed.providerTags,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof McpSessionLostError) throw error;
|
if (error instanceof McpSessionLostError) throw error;
|
||||||
@@ -852,17 +1061,14 @@ async function resolveAccountInSession(
|
|||||||
const endpoint = buildMcpUrl(config);
|
const endpoint = buildMcpUrl(config);
|
||||||
const timeoutMs = Math.max(5_000, config.timeoutMs ?? 30_000);
|
const timeoutMs = Math.max(5_000, config.timeoutMs ?? 30_000);
|
||||||
let noteId = xhsNoteIdFromUrl(publishUrl);
|
let noteId = xhsNoteIdFromUrl(publishUrl);
|
||||||
let publicPageProfile: XhsAccountProfile | null = null;
|
const linkPage = await xhsNoteIdFromShortLink(
|
||||||
if (!noteId) {
|
publishUrl,
|
||||||
const shortLink = await xhsNoteIdFromShortLink(
|
fallbackNickname,
|
||||||
publishUrl,
|
fetchImpl,
|
||||||
fallbackNickname,
|
timeoutMs,
|
||||||
fetchImpl,
|
);
|
||||||
timeoutMs,
|
noteId = noteId || linkPage.noteId;
|
||||||
);
|
const publicPageProfile = linkPage.profile;
|
||||||
noteId = shortLink.noteId;
|
|
||||||
publicPageProfile = shortLink.profile;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionId = await createMcpSession(
|
const sessionId = await createMcpSession(
|
||||||
@@ -870,47 +1076,39 @@ async function resolveAccountInSession(
|
|||||||
endpoint,
|
endpoint,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
);
|
);
|
||||||
if (!noteId) {
|
const noteResult = await callMcpTool(
|
||||||
const noteResult = await callMcpTool(
|
|
||||||
fetchImpl,
|
|
||||||
endpoint,
|
|
||||||
sessionId,
|
|
||||||
timeoutMs,
|
|
||||||
"fetch_content_detail",
|
|
||||||
{
|
|
||||||
link: publishUrl,
|
|
||||||
plant: "xhs",
|
|
||||||
include_comments: false,
|
|
||||||
auto_cookie: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const noteData = successfulToolData(
|
|
||||||
noteResult,
|
|
||||||
"无法识别小红书笔记",
|
|
||||||
);
|
|
||||||
noteId =
|
|
||||||
stringValue(noteData.noteId ?? noteData.note_id) ||
|
|
||||||
findStringByKey(noteData, "noteId") ||
|
|
||||||
findStringByKey(noteData, "note_id");
|
|
||||||
}
|
|
||||||
if (!noteId) throw new Error("无法从发布链接识别小红书笔记ID");
|
|
||||||
|
|
||||||
const authorResult = await callMcpTool(
|
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
endpoint,
|
endpoint,
|
||||||
sessionId,
|
sessionId,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
"collect_xhs_wen_note_detail",
|
"fetch_content_detail",
|
||||||
{
|
{
|
||||||
note_id: noteId,
|
link: publishUrl,
|
||||||
need_desc: false,
|
plant: "xhs",
|
||||||
include_raw: false,
|
include_comments: false,
|
||||||
|
auto_cookie: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const profile = accountProfileFromToolResult(
|
const noteData = successfulToolData(
|
||||||
authorResult,
|
noteResult,
|
||||||
fallbackNickname,
|
"无法识别小红书笔记",
|
||||||
);
|
);
|
||||||
|
noteId =
|
||||||
|
noteId ||
|
||||||
|
stringValue(noteData.noteId ?? noteData.note_id) ||
|
||||||
|
findStringByKey(noteData, "noteId") ||
|
||||||
|
findStringByKey(noteData, "note_id");
|
||||||
|
if (!noteId) throw new Error("无法从发布链接识别小红书笔记ID");
|
||||||
|
let profile: XhsAccountProfile;
|
||||||
|
try {
|
||||||
|
profile = accountProfileFromToolResult(
|
||||||
|
noteResult,
|
||||||
|
fallbackNickname,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
if (!publicPageProfile) throw new Error("笔记数据缺少公开作者主页");
|
||||||
|
profile = publicPageProfile;
|
||||||
|
}
|
||||||
return completeAccountProfile(
|
return completeAccountProfile(
|
||||||
profile,
|
profile,
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
@@ -937,6 +1135,7 @@ async function resolveAccountInSession(
|
|||||||
|
|
||||||
async function collectInSession(
|
async function collectInSession(
|
||||||
publishUrl: string,
|
publishUrl: string,
|
||||||
|
platform: "小红书" | "抖音",
|
||||||
config: CollectionMcpConfig,
|
config: CollectionMcpConfig,
|
||||||
fetchImpl: typeof fetch,
|
fetchImpl: typeof fetch,
|
||||||
) {
|
) {
|
||||||
@@ -952,28 +1151,12 @@ async function collectInSession(
|
|||||||
"fetch_content_detail",
|
"fetch_content_detail",
|
||||||
{
|
{
|
||||||
link: publishUrl,
|
link: publishUrl,
|
||||||
plant: "xhs",
|
plant: platform === "抖音" ? "dy" : "xhs",
|
||||||
include_comments: false,
|
include_comments: false,
|
||||||
auto_cookie: true,
|
auto_cookie: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
try {
|
return metricsFromToolResult(primary);
|
||||||
return metricsFromToolResult(primary);
|
|
||||||
} catch {
|
|
||||||
const fallback = await callMcpTool(
|
|
||||||
fetchImpl,
|
|
||||||
endpoint,
|
|
||||||
sessionId,
|
|
||||||
timeoutMs,
|
|
||||||
"parse_xhs_note",
|
|
||||||
{
|
|
||||||
url: publishUrl,
|
|
||||||
include_comments: false,
|
|
||||||
auto_cookie: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return metricsFromToolResult(fallback);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveCollectionMcpConfig(
|
export function resolveCollectionMcpConfig(
|
||||||
@@ -1005,7 +1188,7 @@ export async function collectXhsMetricsFromMcp(
|
|||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
for (let attempt = 0; attempt < MAX_MCP_ATTEMPTS; attempt += 1) {
|
for (let attempt = 0; attempt < MAX_MCP_ATTEMPTS; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
return await collectInSession(parsed.toString(), config, fetchImpl);
|
return await collectInSession(parsed.toString(), "小红书", config, fetchImpl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (!isRetryableTransportError(error)) throw error;
|
if (!isRetryableTransportError(error)) throw error;
|
||||||
@@ -1022,6 +1205,211 @@ export async function collectXhsMetricsFromMcp(
|
|||||||
: new Error("MCP采集服务暂时不可用");
|
: new Error("MCP采集服务暂时不可用");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function collectMetricsFromMcp(
|
||||||
|
publishUrl: string,
|
||||||
|
platform: "小红书" | "抖音",
|
||||||
|
config: CollectionMcpConfig,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
) {
|
||||||
|
if (platform === "小红书") {
|
||||||
|
return collectXhsMetricsFromMcp(publishUrl, config, fetchImpl);
|
||||||
|
}
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(publishUrl);
|
||||||
|
} catch {
|
||||||
|
throw new Error("发布链接无效");
|
||||||
|
}
|
||||||
|
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||||
|
throw new Error("发布链接无效");
|
||||||
|
}
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 0; attempt < MAX_MCP_ATTEMPTS; attempt += 1) {
|
||||||
|
try {
|
||||||
|
return await collectInSession(parsed.toString(), platform, config, fetchImpl);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (!isRetryableTransportError(error)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error
|
||||||
|
? lastError
|
||||||
|
: new Error("MCP采集服务暂时不可用");
|
||||||
|
}
|
||||||
|
|
||||||
|
function douyinProfileFromToolResult(
|
||||||
|
result: ToolResult,
|
||||||
|
fallbackNickname: string,
|
||||||
|
): XhsAccountProfile {
|
||||||
|
const data = successfulToolData(result, "无法识别抖音作品");
|
||||||
|
const author = findRecord(data, (record) =>
|
||||||
|
Boolean(
|
||||||
|
stringValue(
|
||||||
|
record.sec_uid ?? record.secUid ?? record.uid ?? record.user_id ?? record.userId,
|
||||||
|
) && stringValue(record.nickname ?? record.name ?? record.unique_id ?? record.uniqueId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!author) throw new Error("抖音作品数据缺少作者信息");
|
||||||
|
const verifiedSecUid = usableDouyinSecUid(author.sec_uid ?? author.secUid);
|
||||||
|
const fallbackUid = stringValue(author.uid ?? author.user_id ?? author.userId);
|
||||||
|
const platformUid = verifiedSecUid || fallbackUid;
|
||||||
|
const publicId = stringValue(
|
||||||
|
author.unique_id ?? author.uniqueId ?? author.short_id ?? author.shortId ?? author.douyin_id,
|
||||||
|
);
|
||||||
|
const candidateProfileUrl = verifiedDouyinProfileUrl(
|
||||||
|
author.profile_url ?? author.profileUrl,
|
||||||
|
);
|
||||||
|
const profileUrl = candidateProfileUrl ||
|
||||||
|
(verifiedSecUid
|
||||||
|
? `https://www.douyin.com/user/${encodeURIComponent(verifiedSecUid)}`
|
||||||
|
: "");
|
||||||
|
return {
|
||||||
|
platformUid,
|
||||||
|
nickname: stringValue(author.nickname ?? author.name) || fallbackNickname.trim(),
|
||||||
|
profileUrl,
|
||||||
|
redId: publicId,
|
||||||
|
ipLocation:
|
||||||
|
stringValue(author.ip_location ?? author.ipLocation) ||
|
||||||
|
findStringByKey(data, "ip_location") ||
|
||||||
|
findStringByKey(data, "ipLocation") ||
|
||||||
|
"待识别",
|
||||||
|
followers: followerCountFromPayload(author) ?? followerCountFromPayload(data),
|
||||||
|
gender: profileGender(author.gender ?? author.sex),
|
||||||
|
bio: stringValue(author.desc ?? author.description ?? author.signature ?? author.bio),
|
||||||
|
recentNoteTitles: recentNoteTitlesFromPayload(data),
|
||||||
|
providerTags: providerTagsFromUser(data),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDouyinAccountInSession(
|
||||||
|
publishUrl: string,
|
||||||
|
fallbackNickname: string,
|
||||||
|
config: CollectionMcpConfig,
|
||||||
|
fetchImpl: typeof fetch,
|
||||||
|
) {
|
||||||
|
const endpoint = buildMcpUrl(config);
|
||||||
|
const timeoutMs = Math.max(5_000, config.timeoutMs ?? 30_000);
|
||||||
|
const sessionId = await createMcpSession(fetchImpl, endpoint, timeoutMs);
|
||||||
|
const result = await callMcpTool(
|
||||||
|
fetchImpl,
|
||||||
|
endpoint,
|
||||||
|
sessionId,
|
||||||
|
timeoutMs,
|
||||||
|
"fetch_content_detail",
|
||||||
|
{
|
||||||
|
link: publishUrl,
|
||||||
|
plant: "dy",
|
||||||
|
include_comments: false,
|
||||||
|
auto_cookie: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let profile = douyinProfileFromToolResult(result, fallbackNickname);
|
||||||
|
if (!verifiedDouyinProfileUrl(profile.profileUrl)) {
|
||||||
|
const resolved = await douyinProfileFromPublicRedirect(
|
||||||
|
publishUrl,
|
||||||
|
fetchImpl,
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
if (resolved) {
|
||||||
|
profile = {
|
||||||
|
...profile,
|
||||||
|
platformUid: resolved.platformUid,
|
||||||
|
profileUrl: resolved.profileUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!verifiedDouyinProfileUrl(profile.profileUrl)) return profile;
|
||||||
|
try {
|
||||||
|
const detailsResult = await callMcpTool(
|
||||||
|
fetchImpl,
|
||||||
|
endpoint,
|
||||||
|
sessionId,
|
||||||
|
timeoutMs,
|
||||||
|
"parse_dy_user_summary",
|
||||||
|
{ url: profile.profileUrl },
|
||||||
|
);
|
||||||
|
const details = profileDetailsFromToolResult(detailsResult);
|
||||||
|
profile = {
|
||||||
|
...profile,
|
||||||
|
nickname: details.nickname || profile.nickname,
|
||||||
|
redId: details.redId || profile.redId,
|
||||||
|
followers: details.followers ?? profile.followers,
|
||||||
|
ipLocation:
|
||||||
|
details.ipLocation && details.ipLocation !== "待识别"
|
||||||
|
? details.ipLocation
|
||||||
|
: profile.ipLocation,
|
||||||
|
gender: details.gender || profile.gender,
|
||||||
|
bio: details.bio || profile.bio,
|
||||||
|
recentNoteTitles:
|
||||||
|
details.recentNoteTitles.length > 0
|
||||||
|
? details.recentNoteTitles
|
||||||
|
: profile.recentNoteTitles,
|
||||||
|
providerTags:
|
||||||
|
details.providerTags.length > 0
|
||||||
|
? details.providerTags
|
||||||
|
: profile.providerTags,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof McpSessionLostError) throw error;
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveAccountProfileFromMcp(
|
||||||
|
publishUrl: string,
|
||||||
|
fallbackNickname: string,
|
||||||
|
platform: "小红书" | "抖音",
|
||||||
|
config: CollectionMcpConfig,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
) {
|
||||||
|
if (platform === "小红书") {
|
||||||
|
return resolveXhsAccountProfileFromMcp(
|
||||||
|
publishUrl,
|
||||||
|
fallbackNickname,
|
||||||
|
config,
|
||||||
|
fetchImpl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 0; attempt < MAX_MCP_ATTEMPTS; attempt += 1) {
|
||||||
|
try {
|
||||||
|
return await resolveDouyinAccountInSession(
|
||||||
|
publishUrl,
|
||||||
|
fallbackNickname,
|
||||||
|
config,
|
||||||
|
fetchImpl,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (!isRetryableTransportError(error)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error ? lastError : new Error("抖音账号识别失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveProfileDetailsFromMcp(
|
||||||
|
profileUrl: string,
|
||||||
|
platform: "小红书" | "抖音",
|
||||||
|
config: CollectionMcpConfig,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
) {
|
||||||
|
if (platform === "小红书") {
|
||||||
|
return resolveXhsProfileDetailsFromMcp(profileUrl, config, fetchImpl);
|
||||||
|
}
|
||||||
|
const endpoint = buildMcpUrl(config);
|
||||||
|
const timeoutMs = Math.max(5_000, config.timeoutMs ?? 30_000);
|
||||||
|
const sessionId = await createMcpSession(fetchImpl, endpoint, timeoutMs);
|
||||||
|
const result = await callMcpTool(
|
||||||
|
fetchImpl,
|
||||||
|
endpoint,
|
||||||
|
sessionId,
|
||||||
|
timeoutMs,
|
||||||
|
"parse_dy_user_summary",
|
||||||
|
{ url: profileUrl },
|
||||||
|
);
|
||||||
|
return profileDetailsFromToolResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveXhsAccountProfileFromMcp(
|
export async function resolveXhsAccountProfileFromMcp(
|
||||||
publishUrl: string,
|
publishUrl: string,
|
||||||
fallbackNickname: string,
|
fallbackNickname: string,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type CollectionMcpBindings,
|
type CollectionMcpBindings,
|
||||||
} from "./mcp-collection-client";
|
} from "./mcp-collection-client";
|
||||||
import { ensureSchema, getRawDb } from "./mvp-db";
|
import { ensureSchema, getRawDb } from "./mvp-db";
|
||||||
import { extractXhsPublishUrl } from "./publish-url";
|
import { extractAnyPublishUrl } from "./publish-url";
|
||||||
import { buildClaimUrl } from "./task-service";
|
import { buildClaimUrl } from "./task-service";
|
||||||
|
|
||||||
export type McpOperationBindings = CollectionMcpBindings & {
|
export type McpOperationBindings = CollectionMcpBindings & {
|
||||||
@@ -123,9 +123,9 @@ export async function taskGet(taskId: string, portalUrl: string) {
|
|||||||
const [notes, claims, runs] = await Promise.all([
|
const [notes, claims, runs] = await Promise.all([
|
||||||
db
|
db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT c.id AS content_id, c.source_row, c.title, c.body, c.image_assets, c.status AS content_status,
|
`SELECT c.id AS content_id, c.source_row, c.title, c.body, c.image_assets, c.video_assets, c.status AS content_status,
|
||||||
d.id AS distribution_id, d.status AS distribution_status, d.publish_url, d.publish_time,
|
d.id AS distribution_id, d.status AS distribution_status, d.publish_url, d.publish_time,
|
||||||
d.exposure, d.views, d.latest_likes, d.latest_comments, d.latest_collects,
|
d.exposure, d.views, d.latest_likes, d.latest_comments, d.latest_collects, d.latest_shares,
|
||||||
d.collection_status, d.collection_status_description, d.collection_updated_at,
|
d.collection_status, d.collection_status_description, d.collection_updated_at,
|
||||||
a.id AS account_id, a.nickname AS account_nickname, a.profile_url, a.followers,
|
a.id AS account_id, a.nickname AS account_nickname, a.profile_url, a.followers,
|
||||||
p.name AS cooperation_source, cl.claimant_name
|
p.name AS cooperation_source, cl.claimant_name
|
||||||
@@ -172,12 +172,14 @@ export async function taskGet(taskId: string, portalUrl: string) {
|
|||||||
notes: notes.results.map((row) => ({
|
notes: notes.results.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
image_assets: parseJsonArray(row.image_assets),
|
image_assets: parseJsonArray(row.image_assets),
|
||||||
|
video_assets: parseJsonArray(row.video_assets),
|
||||||
total_interactions:
|
total_interactions:
|
||||||
row.latest_likes == null
|
row.latest_likes == null
|
||||||
? null
|
? null
|
||||||
: Number(row.latest_likes) +
|
: Number(row.latest_likes) +
|
||||||
Number(row.latest_comments ?? 0) +
|
Number(row.latest_comments ?? 0) +
|
||||||
Number(row.latest_collects ?? 0),
|
Number(row.latest_collects ?? 0) +
|
||||||
|
Number(row.latest_shares ?? 0),
|
||||||
})),
|
})),
|
||||||
claims: claims.results,
|
claims: claims.results,
|
||||||
collection_runs: runs.results,
|
collection_runs: runs.results,
|
||||||
@@ -220,7 +222,8 @@ export async function recoveryList(
|
|||||||
const [rows, count] = await Promise.all([
|
const [rows, count] = await Promise.all([
|
||||||
db
|
db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT d.*, t.name AS task_name, c.source_row, c.title,
|
`SELECT d.*, t.name AS task_name, t.platform AS task_platform,
|
||||||
|
t.content_format, c.source_row, c.title,
|
||||||
a.nickname AS account_nickname, a.profile_url, p.name AS cooperation_source,
|
a.nickname AS account_nickname, a.profile_url, p.name AS cooperation_source,
|
||||||
cl.claimant_name ${base}
|
cl.claimant_name ${base}
|
||||||
ORDER BY COALESCE(d.publish_time, d.claimed_at) DESC LIMIT ${limit} OFFSET ${offset}`,
|
ORDER BY COALESCE(d.publish_time, d.claimed_at) DESC LIMIT ${limit} OFFSET ${offset}`,
|
||||||
@@ -241,7 +244,7 @@ export async function recoveryList(
|
|||||||
total_interactions:
|
total_interactions:
|
||||||
row.latest_likes == null
|
row.latest_likes == null
|
||||||
? null
|
? null
|
||||||
: Number(row.latest_likes) + Number(row.latest_comments ?? 0) + Number(row.latest_collects ?? 0),
|
: Number(row.latest_likes) + Number(row.latest_comments ?? 0) + Number(row.latest_collects ?? 0) + Number(row.latest_shares ?? 0),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -358,9 +361,11 @@ function resourceWhere(input: ResourceFilters) {
|
|||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
const bindings: unknown[] = [];
|
const bindings: unknown[] = [];
|
||||||
if (input.query?.trim()) {
|
if (input.query?.trim()) {
|
||||||
conditions.push("(a.nickname LIKE ? ESCAPE '\\' OR a.public_account_id LIKE ? ESCAPE '\\')");
|
conditions.push(
|
||||||
|
"(a.nickname LIKE ? ESCAPE '\\' OR a.public_account_id LIKE ? ESCAPE '\\' OR a.current_contact LIKE ? ESCAPE '\\' OR a.tags LIKE ? ESCAPE '\\' OR a.bio LIKE ? ESCAPE '\\')",
|
||||||
|
);
|
||||||
const pattern = like(input.query.trim());
|
const pattern = like(input.query.trim());
|
||||||
bindings.push(pattern, pattern);
|
bindings.push(pattern, pattern, pattern, pattern, pattern);
|
||||||
}
|
}
|
||||||
if (input.ipLocation?.trim()) {
|
if (input.ipLocation?.trim()) {
|
||||||
conditions.push("a.ip_location LIKE ? ESCAPE '\\'");
|
conditions.push("a.ip_location LIKE ? ESCAPE '\\'");
|
||||||
@@ -369,8 +374,12 @@ function resourceWhere(input: ResourceFilters) {
|
|||||||
if (input.cooperationSource?.trim()) {
|
if (input.cooperationSource?.trim()) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
`(a.cooperation_source LIKE ? ESCAPE '\\' OR
|
`(a.cooperation_source LIKE ? ESCAPE '\\' OR
|
||||||
EXISTS (SELECT 1 FROM distributions dx JOIN partners px ON px.id = dx.partner_id
|
EXISTS (SELECT 1 FROM distributions dx
|
||||||
WHERE dx.account_id = a.id AND px.name LIKE ? ESCAPE '\\'))`,
|
JOIN partners px ON px.id = dx.partner_id
|
||||||
|
LEFT JOIN claims cx ON cx.id = dx.claim_id
|
||||||
|
WHERE dx.account_id = a.id
|
||||||
|
AND (cx.claimant_name IS NULL OR px.name != cx.claimant_name)
|
||||||
|
AND px.name LIKE ? ESCAPE '\\'))`,
|
||||||
);
|
);
|
||||||
const pattern = like(input.cooperationSource.trim());
|
const pattern = like(input.cooperationSource.trim());
|
||||||
bindings.push(pattern, pattern);
|
bindings.push(pattern, pattern);
|
||||||
@@ -389,7 +398,12 @@ export async function resourceSearch(input: ResourceFilters) {
|
|||||||
const db = getRawDb();
|
const db = getRawDb();
|
||||||
const select = `SELECT a.*,
|
const select = `SELECT a.*,
|
||||||
(SELECT COUNT(*) FROM distributions d WHERE d.account_id = a.id) AS cooperation_count,
|
(SELECT COUNT(*) FROM distributions d WHERE d.account_id = a.id) AS cooperation_count,
|
||||||
(SELECT GROUP_CONCAT(DISTINCT p.name) FROM distributions d JOIN partners p ON p.id = d.partner_id WHERE d.account_id = a.id) AS cooperation_sources`;
|
(SELECT GROUP_CONCAT(DISTINCT p.name)
|
||||||
|
FROM distributions d
|
||||||
|
JOIN partners p ON p.id = d.partner_id
|
||||||
|
LEFT JOIN claims c ON c.id = d.claim_id
|
||||||
|
WHERE d.account_id = a.id
|
||||||
|
AND (c.claimant_name IS NULL OR p.name != c.claimant_name)) AS cooperation_sources`;
|
||||||
const [rows, count] = await Promise.all([
|
const [rows, count] = await Promise.all([
|
||||||
db.prepare(`${select} FROM accounts a ${where} ORDER BY a.last_seen_at DESC LIMIT ${limit} OFFSET ${offset}`)
|
db.prepare(`${select} FROM accounts a ${where} ORDER BY a.last_seen_at DESC LIMIT ${limit} OFFSET ${offset}`)
|
||||||
.bind(...bindings).all<Record<string, unknown>>(),
|
.bind(...bindings).all<Record<string, unknown>>(),
|
||||||
@@ -422,7 +436,7 @@ export async function resourceGet(accountId: string) {
|
|||||||
if (!account) throw new Error("账号不存在");
|
if (!account) throw new Error("账号不存在");
|
||||||
const history = await db.prepare(
|
const history = await db.prepare(
|
||||||
`SELECT d.id AS distribution_id, d.task_id, t.name AS task_name, c.title,
|
`SELECT d.id AS distribution_id, d.task_id, t.name AS task_name, c.title,
|
||||||
d.publish_url, d.publish_time, d.latest_likes, d.latest_comments, d.latest_collects,
|
d.publish_url, d.publish_time, d.latest_likes, d.latest_comments, d.latest_collects, d.latest_shares,
|
||||||
d.exposure, d.views, p.name AS cooperation_source, cl.claimant_name
|
d.exposure, d.views, p.name AS cooperation_source, cl.claimant_name
|
||||||
FROM distributions d JOIN tasks t ON t.id = d.task_id
|
FROM distributions d JOIN tasks t ON t.id = d.task_id
|
||||||
JOIN contents c ON c.id = d.content_id JOIN partners p ON p.id = d.partner_id
|
JOIN contents c ON c.id = d.content_id JOIN partners p ON p.id = d.partner_id
|
||||||
@@ -438,7 +452,7 @@ export async function backfillResourceProfile(
|
|||||||
) {
|
) {
|
||||||
await ensureSchema();
|
await ensureSchema();
|
||||||
const db = getRawDb();
|
const db = getRawDb();
|
||||||
const publishUrl = input.publishUrl ? extractXhsPublishUrl(input.publishUrl) : "";
|
const publishUrl = input.publishUrl ? extractAnyPublishUrl(input.publishUrl) : "";
|
||||||
const row = input.distributionId
|
const row = input.distributionId
|
||||||
? await db.prepare(
|
? await db.prepare(
|
||||||
`SELECT d.id, d.publish_url, COALESCE(a.nickname, '') AS nickname
|
`SELECT d.id, d.publish_url, COALESCE(a.nickname, '') AS nickname
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const pagination = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resourceFilters = {
|
const resourceFilters = {
|
||||||
query: z.string().max(100).optional().describe("账号名称或小红书号/抖音号,支持模糊搜索"),
|
query: z.string().max(100).optional().describe("账号名称、账号号、简介或标签,支持模糊搜索"),
|
||||||
ip_location: z.string().max(100).optional().describe("IP地区关键词,支持模糊搜索"),
|
ip_location: z.string().max(100).optional().describe("IP地区关键词,支持模糊搜索"),
|
||||||
cooperation_source: z.string().max(100).optional().describe("历史合作来源关键词,支持模糊搜索"),
|
cooperation_source: z.string().max(100).optional().describe("历史合作来源关键词,支持模糊搜索"),
|
||||||
platform: z.string().max(30).optional().describe("平台,例如小红书;不传表示全部"),
|
platform: z.string().max(30).optional().describe("平台,例如小红书;不传表示全部"),
|
||||||
@@ -159,7 +159,7 @@ export function registerMcpOperationTools(server: McpServer, options: Options) {
|
|||||||
"collection_collect_now",
|
"collection_collect_now",
|
||||||
{
|
{
|
||||||
title: "立即采集指定笔记",
|
title: "立即采集指定笔记",
|
||||||
description: "对指定分发记录立即采集点赞、收藏和评论数据。",
|
description: "对指定分发记录立即采集互动数据;小红书为点赞/收藏/评论,抖音另含转发。",
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
distribution_id: z.string().min(1).describe("分发记录ID,可从 task_get 或 recovery_list 获取"),
|
distribution_id: z.string().min(1).describe("分发记录ID,可从 task_get 或 recovery_list 获取"),
|
||||||
schedule_day: z.number().int().min(1).max(7).optional().describe("标记为第几天采集,可不传"),
|
schedule_day: z.number().int().min(1).max(7).optional().describe("标记为第几天采集,可不传"),
|
||||||
@@ -190,7 +190,7 @@ export function registerMcpOperationTools(server: McpServer, options: Options) {
|
|||||||
"resource_search",
|
"resource_search",
|
||||||
{
|
{
|
||||||
title: "搜索 KOC 账号资源",
|
title: "搜索 KOC 账号资源",
|
||||||
description: "按账号名称/账号号、IP地区、合作来源或平台搜索 KOC 资源。",
|
description: "按账号名称/账号号/标签、IP地区、合作来源或平台搜索 KOC 资源。",
|
||||||
inputSchema: z.object({ ...resourceFilters, ...pagination }),
|
inputSchema: z.object({ ...resourceFilters, ...pagination }),
|
||||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
||||||
},
|
},
|
||||||
@@ -204,7 +204,7 @@ export function registerMcpOperationTools(server: McpServer, options: Options) {
|
|||||||
"resource_get",
|
"resource_get",
|
||||||
{
|
{
|
||||||
title: "查看 KOC 账号详情",
|
title: "查看 KOC 账号详情",
|
||||||
description: "查看账号主页、账号号、粉丝数、IP地区以及全部合作记录。",
|
description: "查看账号主页、账号号、粉丝数、性别、简介、标签、IP地区以及全部合作记录。",
|
||||||
inputSchema: z.object({ account_id: z.string().min(1).describe("KOC LOOP 账号ID") }),
|
inputSchema: z.object({ account_id: z.string().min(1).describe("KOC LOOP 账号ID") }),
|
||||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
||||||
},
|
},
|
||||||
@@ -218,10 +218,10 @@ export function registerMcpOperationTools(server: McpServer, options: Options) {
|
|||||||
"resource_backfill_profile",
|
"resource_backfill_profile",
|
||||||
{
|
{
|
||||||
title: "补全公开账号信息",
|
title: "补全公开账号信息",
|
||||||
description: "根据已回填的发布链接补全账号主页、昵称、小红书号、IP地区和粉丝数。",
|
description: "根据已回填的小红书或抖音作品链接补全账号主页、昵称、账号号、IP地区和粉丝数。",
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
distribution_id: z.string().optional().describe("分发记录ID,和发布链接二选一"),
|
distribution_id: z.string().optional().describe("分发记录ID,和发布链接二选一"),
|
||||||
publish_url: z.string().optional().describe("小红书发布链接或包含链接的分享文案"),
|
publish_url: z.string().optional().describe("小红书或抖音作品链接,也可传包含链接的分享文案"),
|
||||||
}).refine((value) => Boolean(value.distribution_id || value.publish_url), "请提供分发记录ID或发布链接"),
|
}).refine((value) => Boolean(value.distribution_id || value.publish_url), "请提供分发记录ID或发布链接"),
|
||||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export function getUploadBucket() {
|
|||||||
|
|
||||||
export async function ensureSchema(database?: DatabaseClient) {
|
export async function ensureSchema(database?: DatabaseClient) {
|
||||||
const db = database ?? getRawDb();
|
const db = database ?? getRawDb();
|
||||||
await ensurePartnersWecomColumn(db);
|
|
||||||
{
|
{
|
||||||
await db.prepare("SELECT id FROM tasks LIMIT 1").all();
|
await db.prepare("SELECT id FROM tasks LIMIT 1").all();
|
||||||
|
|
||||||
@@ -82,7 +81,6 @@ export async function ensureSchema(database?: DatabaseClient) {
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
wecom_name TEXT NOT NULL,
|
wecom_name TEXT NOT NULL,
|
||||||
owner TEXT NOT NULL DEFAULT '运营组',
|
owner TEXT NOT NULL DEFAULT '运营组',
|
||||||
wecom_external_user_id TEXT,
|
|
||||||
claimed_total INTEGER NOT NULL DEFAULT 0,
|
claimed_total INTEGER NOT NULL DEFAULT 0,
|
||||||
completed_total INTEGER NOT NULL DEFAULT 0,
|
completed_total INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
@@ -96,6 +94,8 @@ export async function ensureSchema(database?: DatabaseClient) {
|
|||||||
due_at TEXT NOT NULL,
|
due_at TEXT NOT NULL,
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
task_type TEXT NOT NULL DEFAULT 'content_publish',
|
task_type TEXT NOT NULL DEFAULT 'content_publish',
|
||||||
|
platform TEXT NOT NULL DEFAULT '小红书',
|
||||||
|
content_format TEXT NOT NULL DEFAULT 'image_text',
|
||||||
source_url TEXT NOT NULL DEFAULT '',
|
source_url TEXT NOT NULL DEFAULT '',
|
||||||
source_sheet_id TEXT NOT NULL DEFAULT '',
|
source_sheet_id TEXT NOT NULL DEFAULT '',
|
||||||
source_sheet_name TEXT NOT NULL DEFAULT '',
|
source_sheet_name TEXT NOT NULL DEFAULT '',
|
||||||
@@ -112,6 +112,7 @@ export async function ensureSchema(database?: DatabaseClient) {
|
|||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
body TEXT NOT NULL DEFAULT '',
|
body TEXT NOT NULL DEFAULT '',
|
||||||
image_assets TEXT NOT NULL DEFAULT '[]',
|
image_assets TEXT NOT NULL DEFAULT '[]',
|
||||||
|
video_assets TEXT NOT NULL DEFAULT '[]',
|
||||||
status TEXT NOT NULL DEFAULT 'available',
|
status TEXT NOT NULL DEFAULT 'available',
|
||||||
source TEXT NOT NULL DEFAULT '飞书内容表',
|
source TEXT NOT NULL DEFAULT '飞书内容表',
|
||||||
source_row INTEGER,
|
source_row INTEGER,
|
||||||
@@ -126,10 +127,13 @@ export async function ensureSchema(database?: DatabaseClient) {
|
|||||||
profile_url TEXT NOT NULL DEFAULT '',
|
profile_url TEXT NOT NULL DEFAULT '',
|
||||||
ip_location TEXT NOT NULL DEFAULT '待识别',
|
ip_location TEXT NOT NULL DEFAULT '待识别',
|
||||||
followers INTEGER NOT NULL DEFAULT 0,
|
followers INTEGER NOT NULL DEFAULT 0,
|
||||||
|
gender TEXT NOT NULL DEFAULT '',
|
||||||
|
bio TEXT NOT NULL DEFAULT '',
|
||||||
|
tags TEXT NOT NULL DEFAULT '',
|
||||||
post_count INTEGER NOT NULL DEFAULT 0,
|
post_count INTEGER NOT NULL DEFAULT 0,
|
||||||
avg_views INTEGER NOT NULL DEFAULT 0,
|
avg_views INTEGER NOT NULL DEFAULT 0,
|
||||||
cooperation_source TEXT NOT NULL DEFAULT '',
|
cooperation_source TEXT NOT NULL DEFAULT '',
|
||||||
tags TEXT NOT NULL DEFAULT '',
|
current_contact TEXT NOT NULL DEFAULT '',
|
||||||
first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
)`,
|
)`,
|
||||||
@@ -188,6 +192,7 @@ export async function ensureSchema(database?: DatabaseClient) {
|
|||||||
latest_likes INTEGER,
|
latest_likes INTEGER,
|
||||||
latest_comments INTEGER,
|
latest_comments INTEGER,
|
||||||
latest_collects INTEGER,
|
latest_collects INTEGER,
|
||||||
|
latest_shares INTEGER,
|
||||||
collection_status TEXT NOT NULL DEFAULT 'pending',
|
collection_status TEXT NOT NULL DEFAULT 'pending',
|
||||||
collection_status_description TEXT,
|
collection_status_description TEXT,
|
||||||
collection_updated_at TEXT,
|
collection_updated_at TEXT,
|
||||||
@@ -205,6 +210,7 @@ export async function ensureSchema(database?: DatabaseClient) {
|
|||||||
likes INTEGER,
|
likes INTEGER,
|
||||||
comments INTEGER,
|
comments INTEGER,
|
||||||
collects INTEGER,
|
collects INTEGER,
|
||||||
|
shares INTEGER,
|
||||||
status_description TEXT,
|
status_description TEXT,
|
||||||
started_at TEXT,
|
started_at TEXT,
|
||||||
completed_at TEXT,
|
completed_at TEXT,
|
||||||
@@ -296,7 +302,14 @@ export async function ensureSchema(database?: DatabaseClient) {
|
|||||||
"public_account_id",
|
"public_account_id",
|
||||||
"public_account_id TEXT NOT NULL DEFAULT ''",
|
"public_account_id TEXT NOT NULL DEFAULT ''",
|
||||||
);
|
);
|
||||||
|
await ensureColumn("accounts", "gender", "gender TEXT NOT NULL DEFAULT ''");
|
||||||
|
await ensureColumn("accounts", "bio", "bio TEXT NOT NULL DEFAULT ''");
|
||||||
await ensureColumn("accounts", "tags", "tags TEXT NOT NULL DEFAULT ''");
|
await ensureColumn("accounts", "tags", "tags TEXT NOT NULL DEFAULT ''");
|
||||||
|
await ensureColumn(
|
||||||
|
"accounts",
|
||||||
|
"current_contact",
|
||||||
|
"current_contact TEXT NOT NULL DEFAULT ''",
|
||||||
|
);
|
||||||
await ensureColumn("distributions", "claim_id", "claim_id TEXT");
|
await ensureColumn("distributions", "claim_id", "claim_id TEXT");
|
||||||
await ensureColumn(
|
await ensureColumn(
|
||||||
"distributions",
|
"distributions",
|
||||||
@@ -800,22 +813,44 @@ export async function getDashboardData() {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
db.prepare("SELECT * FROM partners ORDER BY created_at DESC").all(),
|
db.prepare("SELECT * FROM partners ORDER BY created_at DESC").all(),
|
||||||
db.prepare("SELECT * FROM tasks ORDER BY created_at DESC").all(),
|
db.prepare("SELECT * FROM tasks ORDER BY created_at DESC").all(),
|
||||||
db.prepare("SELECT * FROM accounts ORDER BY last_seen_at DESC").all(),
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT
|
||||||
|
a.*,
|
||||||
|
COALESCE(
|
||||||
|
(
|
||||||
|
SELECT d.publish_url
|
||||||
|
FROM distributions d
|
||||||
|
WHERE d.account_id = a.id
|
||||||
|
AND TRIM(COALESCE(d.publish_url, '')) != ''
|
||||||
|
ORDER BY d.updated_at DESC, d.claimed_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
''
|
||||||
|
) AS latest_publish_url
|
||||||
|
FROM accounts a
|
||||||
|
ORDER BY a.last_seen_at DESC`,
|
||||||
|
)
|
||||||
|
.all(),
|
||||||
db
|
db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT
|
`SELECT
|
||||||
d.*,
|
d.*,
|
||||||
c.title AS content_title,
|
c.title AS content_title,
|
||||||
p.name AS partner_name,
|
p.name AS partner_name,
|
||||||
|
cl.claimant_name AS claimant_name,
|
||||||
a.nickname AS account_nickname,
|
a.nickname AS account_nickname,
|
||||||
a.platform AS account_platform,
|
a.platform AS account_platform,
|
||||||
t.name AS task_name,
|
t.name AS task_name,
|
||||||
t.brand AS task_brand,
|
t.brand AS task_brand,
|
||||||
t.task_type AS task_type,
|
t.task_type AS task_type,
|
||||||
|
t.platform AS task_platform,
|
||||||
|
t.content_format AS content_format,
|
||||||
t.due_at AS due_at
|
t.due_at AS due_at
|
||||||
FROM distributions d
|
FROM distributions d
|
||||||
JOIN contents c ON c.id = d.content_id
|
JOIN contents c ON c.id = d.content_id
|
||||||
JOIN partners p ON p.id = d.partner_id
|
JOIN partners p ON p.id = d.partner_id
|
||||||
|
LEFT JOIN claims cl ON cl.id = d.claim_id
|
||||||
JOIN tasks t ON t.id = d.task_id
|
JOIN tasks t ON t.id = d.task_id
|
||||||
LEFT JOIN accounts a ON a.id = d.account_id
|
LEFT JOIN accounts a ON a.id = d.account_id
|
||||||
ORDER BY d.updated_at DESC, d.claimed_at DESC`,
|
ORDER BY d.updated_at DESC, d.claimed_at DESC`,
|
||||||
@@ -836,32 +871,6 @@ export function uid(prefix: string) {
|
|||||||
return `${prefix}-${crypto.randomUUID().slice(0, 8)}`;
|
return `${prefix}-${crypto.randomUUID().slice(0, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensurePartnersWecomColumn(db: DatabaseClient) {
|
|
||||||
try {
|
|
||||||
const rows = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT COLUMN_NAME
|
|
||||||
FROM INFORMATION_SCHEMA.COLUMNS
|
|
||||||
WHERE TABLE_SCHEMA = DATABASE()
|
|
||||||
AND TABLE_NAME = 'partners'
|
|
||||||
AND COLUMN_NAME = 'wecom_external_user_id'`,
|
|
||||||
)
|
|
||||||
.all<{ COLUMN_NAME: string }>();
|
|
||||||
if (rows.results.length === 0) {
|
|
||||||
await db
|
|
||||||
.prepare(
|
|
||||||
"ALTER TABLE partners ADD COLUMN wecom_external_user_id TEXT",
|
|
||||||
)
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
"[KOC LOOP] failed to ensure partners.wecom_external_user_id column",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hashText(value: string) {
|
export function hashText(value: string) {
|
||||||
let hash = 2166136261;
|
let hash = 2166136261;
|
||||||
for (let index = 0; index < value.length; index += 1) {
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
|||||||
547
lib/partner-batch-workbook.ts
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { strFromU8, unzipSync } from "fflate";
|
||||||
|
|
||||||
|
export const PARTNER_BATCH_HEADERS = [
|
||||||
|
"序号(不能改)",
|
||||||
|
"标题",
|
||||||
|
"笔记内容(正文+话题)",
|
||||||
|
"图片",
|
||||||
|
"发布链接",
|
||||||
|
"笔记截图",
|
||||||
|
"数据分析截图(单篇笔记数据分析截图)",
|
||||||
|
"_系统笔记ID",
|
||||||
|
"_原笔记截图",
|
||||||
|
"_原数据分析截图",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const PARTNER_BATCH_VISIBLE_COLUMN_COUNT = 7;
|
||||||
|
export const PARTNER_BATCH_MAX_BYTES = 80_000_000;
|
||||||
|
|
||||||
|
export type PartnerBatchWorkbookColumns = {
|
||||||
|
headers: string[];
|
||||||
|
columnWidths: number[];
|
||||||
|
sourceImageStartColumn: number;
|
||||||
|
sourceImageCount: number;
|
||||||
|
sourceVideoStartColumn: number;
|
||||||
|
sourceVideoCount: number;
|
||||||
|
publishUrlColumn: number;
|
||||||
|
publishScreenshotColumn: number;
|
||||||
|
creatorScreenshotColumn: number;
|
||||||
|
systemColumn: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function nonNegativeInteger(value: number) {
|
||||||
|
return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPartnerBatchWorkbookColumns(input: {
|
||||||
|
contentFormat: "image_text" | "video";
|
||||||
|
maxSourceImages: number;
|
||||||
|
maxSourceVideos: number;
|
||||||
|
}): PartnerBatchWorkbookColumns {
|
||||||
|
const sourceImageCount =
|
||||||
|
input.contentFormat === "video"
|
||||||
|
? 0
|
||||||
|
: Math.max(1, nonNegativeInteger(input.maxSourceImages));
|
||||||
|
const sourceVideoCount =
|
||||||
|
input.contentFormat === "video"
|
||||||
|
? Math.max(1, nonNegativeInteger(input.maxSourceVideos))
|
||||||
|
: 0;
|
||||||
|
const sourceImageStartColumn = 3;
|
||||||
|
const sourceVideoStartColumn = sourceImageStartColumn + sourceImageCount;
|
||||||
|
const publishUrlColumn = sourceVideoStartColumn + sourceVideoCount;
|
||||||
|
const publishScreenshotColumn = publishUrlColumn + 1;
|
||||||
|
const creatorScreenshotColumn = publishScreenshotColumn + 1;
|
||||||
|
const systemColumn = creatorScreenshotColumn + 1;
|
||||||
|
return {
|
||||||
|
headers: [
|
||||||
|
"序号(不能改)",
|
||||||
|
"标题",
|
||||||
|
"笔记内容(正文+话题)",
|
||||||
|
...Array.from(
|
||||||
|
{ length: sourceImageCount },
|
||||||
|
(_, index) => `图片${index + 1}`,
|
||||||
|
),
|
||||||
|
...Array.from(
|
||||||
|
{ length: sourceVideoCount },
|
||||||
|
(_, index) => `视频${index + 1}`,
|
||||||
|
),
|
||||||
|
"发布链接",
|
||||||
|
"笔记截图",
|
||||||
|
"数据分析截图(单篇笔记数据分析截图)",
|
||||||
|
"_系统笔记ID",
|
||||||
|
"_原笔记截图",
|
||||||
|
"_原数据分析截图",
|
||||||
|
],
|
||||||
|
columnWidths: [
|
||||||
|
14,
|
||||||
|
30,
|
||||||
|
62,
|
||||||
|
...Array.from({ length: sourceImageCount }, () => 24),
|
||||||
|
...Array.from({ length: sourceVideoCount }, () => 20),
|
||||||
|
45,
|
||||||
|
28,
|
||||||
|
32,
|
||||||
|
22,
|
||||||
|
22,
|
||||||
|
22,
|
||||||
|
],
|
||||||
|
sourceImageStartColumn,
|
||||||
|
sourceImageCount,
|
||||||
|
sourceVideoStartColumn,
|
||||||
|
sourceVideoCount,
|
||||||
|
publishUrlColumn,
|
||||||
|
publishScreenshotColumn,
|
||||||
|
creatorScreenshotColumn,
|
||||||
|
systemColumn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstForwardedValue(value: string | null) {
|
||||||
|
return value?.split(",")[0]?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function httpOrigin(value: string) {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === "http:" || url.protocol === "https:"
|
||||||
|
? url.origin
|
||||||
|
: "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePartnerWorkbookOrigin(
|
||||||
|
request: Request,
|
||||||
|
configuredOrigin = "",
|
||||||
|
) {
|
||||||
|
const requestUrl = new URL(request.url);
|
||||||
|
const host =
|
||||||
|
firstForwardedValue(request.headers.get("x-forwarded-host")) ||
|
||||||
|
firstForwardedValue(request.headers.get("host"));
|
||||||
|
const forwardedProtocol = firstForwardedValue(
|
||||||
|
request.headers.get("x-forwarded-proto"),
|
||||||
|
).toLowerCase();
|
||||||
|
const protocol = ["http", "https"].includes(forwardedProtocol)
|
||||||
|
? forwardedProtocol
|
||||||
|
: requestUrl.protocol.replace(":", "");
|
||||||
|
const proxyOrigin = host ? httpOrigin(`${protocol}://${host}`) : "";
|
||||||
|
return (
|
||||||
|
httpOrigin(configuredOrigin) ||
|
||||||
|
proxyOrigin ||
|
||||||
|
httpOrigin(requestUrl.origin) ||
|
||||||
|
requestUrl.origin
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PartnerBatchImage = {
|
||||||
|
bytes: Uint8Array;
|
||||||
|
contentType: string;
|
||||||
|
fileName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PartnerBatchImportRow = {
|
||||||
|
spreadsheetRow: number;
|
||||||
|
sequence: string;
|
||||||
|
title: string;
|
||||||
|
publishUrl: string;
|
||||||
|
distributionId: string;
|
||||||
|
originalPublishScreenshotKey: string;
|
||||||
|
originalCreatorScreenshotKey: string;
|
||||||
|
publishScreenshot: PartnerBatchImage | null;
|
||||||
|
creatorScreenshot: PartnerBatchImage | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function decodeXml(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/<[^>]+>/g, "")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
|
||||||
|
.replace(/&#x([0-9a-f]+);/gi, (_, code) =>
|
||||||
|
String.fromCodePoint(Number.parseInt(code, 16)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function xmlAttribute(value: string) {
|
||||||
|
return decodeXml(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function textNodes(xml: string) {
|
||||||
|
return [...xml.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)]
|
||||||
|
.map((match) => decodeXml(match[1]))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnIndex(reference: string) {
|
||||||
|
const letters = reference.match(/^[A-Z]+/i)?.[0]?.toUpperCase() ?? "";
|
||||||
|
let result = 0;
|
||||||
|
for (const letter of letters) result = result * 26 + letter.charCodeAt(0) - 64;
|
||||||
|
return Math.max(0, result - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWorksheet(xml: string, sharedStrings: string[]) {
|
||||||
|
const rows: string[][] = [];
|
||||||
|
for (const rowMatch of xml.matchAll(/<row\b([^>]*)>([\s\S]*?)<\/row>/g)) {
|
||||||
|
const rowNumber = Number(
|
||||||
|
rowMatch[1].match(/\br="(\d+)"/)?.[1] ?? rows.length + 1,
|
||||||
|
);
|
||||||
|
const values: string[] = [];
|
||||||
|
for (const cellMatch of rowMatch[2].matchAll(
|
||||||
|
/<c\b([^>]*)>([\s\S]*?)<\/c>/g,
|
||||||
|
)) {
|
||||||
|
const attributes = cellMatch[1];
|
||||||
|
const body = cellMatch[2];
|
||||||
|
const reference = attributes.match(/\br="([A-Z]+\d+)"/i)?.[1] ?? "A1";
|
||||||
|
const type = attributes.match(/\bt="([^"]+)"/)?.[1] ?? "";
|
||||||
|
const rawValue = body.match(/<v>([\s\S]*?)<\/v>/)?.[1] ?? "";
|
||||||
|
let value = "";
|
||||||
|
if (type === "s") value = sharedStrings[Number(rawValue)] ?? "";
|
||||||
|
else if (type === "inlineStr") value = textNodes(body);
|
||||||
|
else if (type === "b") value = rawValue === "1" ? "TRUE" : "FALSE";
|
||||||
|
else value = decodeXml(rawValue);
|
||||||
|
values[columnIndex(reference)] = value.trim();
|
||||||
|
}
|
||||||
|
while (rows.length < rowNumber - 1) rows.push([]);
|
||||||
|
rows[rowNumber - 1] = values;
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeader(value: string) {
|
||||||
|
return value.replace(/[\s_\-()()]/g, "").toLocaleLowerCase("zh-CN");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSourceImageHeader(value: string) {
|
||||||
|
return /^(?:图片|发布配图)\d*$/.test(normalizeHeader(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerAliases(header: (typeof PARTNER_BATCH_HEADERS)[number]) {
|
||||||
|
const aliases: Record<string, string[]> = {
|
||||||
|
"序号(不能改)": ["序号(不能改)", "序号"],
|
||||||
|
标题: ["标题"],
|
||||||
|
"笔记内容(正文+话题)": ["笔记内容(正文+话题)", "笔记内容"],
|
||||||
|
图片: ["图片", "发布配图"],
|
||||||
|
发布链接: ["发布链接"],
|
||||||
|
笔记截图: ["笔记截图", "发布截图"],
|
||||||
|
"数据分析截图(单篇笔记数据分析截图)": [
|
||||||
|
"数据分析截图(单篇笔记数据分析截图)",
|
||||||
|
"数据分析截图",
|
||||||
|
"创作者中心截图",
|
||||||
|
],
|
||||||
|
_系统笔记ID: ["_系统笔记ID", "系统笔记ID"],
|
||||||
|
_原笔记截图: ["_原笔记截图"],
|
||||||
|
_原数据分析截图: ["_原数据分析截图"],
|
||||||
|
};
|
||||||
|
return aliases[header] ?? [header];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findHeader(rows: string[][]) {
|
||||||
|
for (let rowIndex = 0; rowIndex < Math.min(rows.length, 8); rowIndex += 1) {
|
||||||
|
const mapping = new Map<(typeof PARTNER_BATCH_HEADERS)[number], number>();
|
||||||
|
rows[rowIndex].forEach((value, column) => {
|
||||||
|
for (const header of PARTNER_BATCH_HEADERS) {
|
||||||
|
if (header === "图片" && isSourceImageHeader(value)) {
|
||||||
|
if (!mapping.has(header)) mapping.set(header, column);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
headerAliases(header).some(
|
||||||
|
(alias) => normalizeHeader(alias) === normalizeHeader(value),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
mapping.set(header, column);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
mapping.has("序号(不能改)") &&
|
||||||
|
mapping.has("标题") &&
|
||||||
|
mapping.has("发布链接") &&
|
||||||
|
mapping.has("笔记截图") &&
|
||||||
|
mapping.has("数据分析截图(单篇笔记数据分析截图)") &&
|
||||||
|
mapping.has("_系统笔记ID")
|
||||||
|
) {
|
||||||
|
return { rowIndex, mapping };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationshipMap(xml: string) {
|
||||||
|
const relationships = new Map<string, string>();
|
||||||
|
for (const match of xml.matchAll(/<Relationship\b([^>]*)\/?\s*>/g)) {
|
||||||
|
const attributes = match[1];
|
||||||
|
const id = attributes.match(/\bId="([^"]+)"/)?.[1] ?? "";
|
||||||
|
const target = attributes.match(/\bTarget="([^"]+)"/)?.[1] ?? "";
|
||||||
|
if (id && target) relationships.set(id, xmlAttribute(target));
|
||||||
|
}
|
||||||
|
return relationships;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentType(bytes: Uint8Array, fileName: string) {
|
||||||
|
if (bytes[0] === 0x89 && bytes[1] === 0x50) return "image/png";
|
||||||
|
if (bytes[0] === 0xff && bytes[1] === 0xd8) return "image/jpeg";
|
||||||
|
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) {
|
||||||
|
return "image/gif";
|
||||||
|
}
|
||||||
|
if (String.fromCharCode(...bytes.slice(8, 12)) === "WEBP") return "image/webp";
|
||||||
|
const extension = path.extname(fileName).toLowerCase();
|
||||||
|
return extension === ".png"
|
||||||
|
? "image/png"
|
||||||
|
: extension === ".gif"
|
||||||
|
? "image/gif"
|
||||||
|
: extension === ".webp"
|
||||||
|
? "image/webp"
|
||||||
|
: "image/jpeg";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveZipPath(base: string, target: string) {
|
||||||
|
return path.posix.normalize(path.posix.join(path.posix.dirname(base), target));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDrawingImages(entries: Record<string, Uint8Array>) {
|
||||||
|
const images = new Map<string, PartnerBatchImage>();
|
||||||
|
const sheetRelationshipsXml = entries["xl/worksheets/_rels/sheet1.xml.rels"]
|
||||||
|
? strFromU8(entries["xl/worksheets/_rels/sheet1.xml.rels"])
|
||||||
|
: "";
|
||||||
|
const sheetRelationships = relationshipMap(sheetRelationshipsXml);
|
||||||
|
const sheetXml = entries["xl/worksheets/sheet1.xml"]
|
||||||
|
? strFromU8(entries["xl/worksheets/sheet1.xml"])
|
||||||
|
: "";
|
||||||
|
const drawingId = sheetXml.match(/<drawing\b[^>]*r:id="([^"]+)"/)?.[1] ?? "";
|
||||||
|
const drawingTarget = sheetRelationships.get(drawingId);
|
||||||
|
if (!drawingTarget) return images;
|
||||||
|
const drawingPath = resolveZipPath("xl/worksheets/sheet1.xml", drawingTarget);
|
||||||
|
const drawingXml = entries[drawingPath] ? strFromU8(entries[drawingPath]) : "";
|
||||||
|
const drawingRelationshipsPath = path.posix.join(
|
||||||
|
path.posix.dirname(drawingPath),
|
||||||
|
"_rels",
|
||||||
|
`${path.posix.basename(drawingPath)}.rels`,
|
||||||
|
);
|
||||||
|
const drawingRelationships = relationshipMap(
|
||||||
|
entries[drawingRelationshipsPath]
|
||||||
|
? strFromU8(entries[drawingRelationshipsPath])
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
for (const anchor of drawingXml.matchAll(
|
||||||
|
/<xdr:(?:oneCellAnchor|twoCellAnchor)\b[^>]*>[\s\S]*?<xdr:from>([\s\S]*?)<\/xdr:from>[\s\S]*?<a:blip\b[^>]*r:embed="([^"]+)"[\s\S]*?<\/xdr:(?:oneCellAnchor|twoCellAnchor)>/g,
|
||||||
|
)) {
|
||||||
|
const column = Number(anchor[1].match(/<xdr:col>(\d+)<\/xdr:col>/)?.[1]);
|
||||||
|
const row = Number(anchor[1].match(/<xdr:row>(\d+)<\/xdr:row>/)?.[1]);
|
||||||
|
const mediaTarget = drawingRelationships.get(anchor[2]);
|
||||||
|
if (!Number.isInteger(column) || !Number.isInteger(row) || !mediaTarget) continue;
|
||||||
|
const mediaPath = resolveZipPath(drawingPath, mediaTarget);
|
||||||
|
const bytes = entries[mediaPath];
|
||||||
|
if (!bytes) continue;
|
||||||
|
images.set(`${row + 1}:${column}`, {
|
||||||
|
bytes,
|
||||||
|
contentType: contentType(bytes, mediaPath),
|
||||||
|
fileName: path.posix.basename(mediaPath),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRichValueImages(entries: Record<string, Uint8Array>) {
|
||||||
|
const images = new Map<string, PartnerBatchImage>();
|
||||||
|
const worksheetXml = entries["xl/worksheets/sheet1.xml"]
|
||||||
|
? strFromU8(entries["xl/worksheets/sheet1.xml"])
|
||||||
|
: "";
|
||||||
|
const metadataXml = entries["xl/metadata.xml"]
|
||||||
|
? strFromU8(entries["xl/metadata.xml"])
|
||||||
|
: "";
|
||||||
|
const richValueXml = entries["xl/richData/rdrichvalue.xml"]
|
||||||
|
? strFromU8(entries["xl/richData/rdrichvalue.xml"])
|
||||||
|
: "";
|
||||||
|
const richValueRelXml = entries["xl/richData/richValueRel.xml"]
|
||||||
|
? strFromU8(entries["xl/richData/richValueRel.xml"])
|
||||||
|
: "";
|
||||||
|
const richValueRelRelationships = relationshipMap(
|
||||||
|
entries["xl/richData/_rels/richValueRel.xml.rels"]
|
||||||
|
? strFromU8(entries["xl/richData/_rels/richValueRel.xml.rels"])
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!worksheetXml ||
|
||||||
|
!metadataXml ||
|
||||||
|
!richValueXml ||
|
||||||
|
!richValueRelXml ||
|
||||||
|
!richValueRelRelationships.size
|
||||||
|
) {
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueMetadataXml =
|
||||||
|
metadataXml.match(/<valueMetadata\b[^>]*>([\s\S]*?)<\/valueMetadata>/)?.[1] ??
|
||||||
|
"";
|
||||||
|
const metadataToRichValue = [
|
||||||
|
...valueMetadataXml.matchAll(/<bk\b[^>]*>[\s\S]*?<rc\b[^>]*\bv="(\d+)"[^>]*\/>[\s\S]*?<\/bk>/g),
|
||||||
|
].map((match) => Number(match[1]));
|
||||||
|
const richValueToRelationship = [
|
||||||
|
...richValueXml.matchAll(/<rv\b[^>]*>([\s\S]*?)<\/rv>/g),
|
||||||
|
].map((match) => Number(match[1].match(/<v>(\d+)<\/v>/)?.[1] ?? -1));
|
||||||
|
const relationshipIds = [
|
||||||
|
...richValueRelXml.matchAll(/<rel\b[^>]*\br:id="([^"]+)"[^>]*\/>/g),
|
||||||
|
].map((match) => match[1]);
|
||||||
|
|
||||||
|
for (const cell of worksheetXml.matchAll(
|
||||||
|
/<c\b([^>]*)>[\s\S]*?<\/c>/g,
|
||||||
|
)) {
|
||||||
|
const reference = cell[1].match(/\br="([A-Z]+\d+)"/i)?.[1] ?? "";
|
||||||
|
const metadataIndex = Number(cell[1].match(/\bvm="(\d+)"/)?.[1] ?? 0);
|
||||||
|
const row = Number(reference.match(/\d+$/)?.[0] ?? 0);
|
||||||
|
const column = columnIndex(reference);
|
||||||
|
if (!reference || !metadataIndex || !row) continue;
|
||||||
|
const richValueIndex = metadataToRichValue[metadataIndex - 1];
|
||||||
|
const relationshipIndex = richValueToRelationship[richValueIndex];
|
||||||
|
const relationshipId = relationshipIds[relationshipIndex];
|
||||||
|
const mediaTarget = richValueRelRelationships.get(relationshipId);
|
||||||
|
if (!mediaTarget) continue;
|
||||||
|
const mediaPath = resolveZipPath(
|
||||||
|
"xl/richData/richValueRel.xml",
|
||||||
|
mediaTarget,
|
||||||
|
);
|
||||||
|
const bytes = entries[mediaPath];
|
||||||
|
if (!bytes) continue;
|
||||||
|
images.set(`${row}:${column}`, {
|
||||||
|
bytes,
|
||||||
|
contentType: contentType(bytes, mediaPath),
|
||||||
|
fileName: path.posix.basename(mediaPath),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWpsCellImages(entries: Record<string, Uint8Array>) {
|
||||||
|
const images = new Map<string, PartnerBatchImage>();
|
||||||
|
const worksheetXml = entries["xl/worksheets/sheet1.xml"]
|
||||||
|
? strFromU8(entries["xl/worksheets/sheet1.xml"])
|
||||||
|
: "";
|
||||||
|
const cellImagesXml = entries["xl/cellimages.xml"]
|
||||||
|
? strFromU8(entries["xl/cellimages.xml"])
|
||||||
|
: "";
|
||||||
|
const relationships = relationshipMap(
|
||||||
|
entries["xl/_rels/cellimages.xml.rels"]
|
||||||
|
? strFromU8(entries["xl/_rels/cellimages.xml.rels"])
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
if (!worksheetXml || !cellImagesXml || !relationships.size) return images;
|
||||||
|
|
||||||
|
const imageIdToRelationship = new Map<string, string>();
|
||||||
|
for (const match of cellImagesXml.matchAll(
|
||||||
|
/<(?:etc:)?cellImage\b[^>]*>([\s\S]*?)<\/(?:etc:)?cellImage>/g,
|
||||||
|
)) {
|
||||||
|
const imageId = match[1].match(
|
||||||
|
/<(?:xdr:)?cNvPr\b[^>]*\bname="([^"]+)"/,
|
||||||
|
)?.[1];
|
||||||
|
const relationshipId = match[1].match(
|
||||||
|
/<(?:a:)?blip\b[^>]*\br:embed="([^"]+)"/,
|
||||||
|
)?.[1];
|
||||||
|
if (imageId && relationshipId) {
|
||||||
|
imageIdToRelationship.set(imageId, relationshipId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cell of worksheetXml.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
|
||||||
|
const reference = cell[1].match(/\br="([A-Z]+\d+)"/i)?.[1] ?? "";
|
||||||
|
const imageId = decodeXml(cell[2]).match(/DISPIMG\("([^"]+)"/i)?.[1];
|
||||||
|
if (!reference || !imageId) continue;
|
||||||
|
const relationshipId = imageIdToRelationship.get(imageId);
|
||||||
|
const mediaTarget = relationshipId
|
||||||
|
? relationships.get(relationshipId)
|
||||||
|
: undefined;
|
||||||
|
if (!mediaTarget) continue;
|
||||||
|
const mediaPath = resolveZipPath("xl/cellimages.xml", mediaTarget);
|
||||||
|
const bytes = entries[mediaPath];
|
||||||
|
if (!bytes) continue;
|
||||||
|
const row = Number(reference.match(/\d+$/)?.[0] ?? 0);
|
||||||
|
if (!row) continue;
|
||||||
|
images.set(`${row}:${columnIndex(reference)}`, {
|
||||||
|
bytes,
|
||||||
|
contentType: contentType(bytes, mediaPath),
|
||||||
|
fileName: path.posix.basename(mediaPath),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseImages(entries: Record<string, Uint8Array>) {
|
||||||
|
const images = parseDrawingImages(entries);
|
||||||
|
for (const [cell, image] of parseRichValueImages(entries)) {
|
||||||
|
images.set(cell, image);
|
||||||
|
}
|
||||||
|
for (const [cell, image] of parseWpsCellImages(entries)) {
|
||||||
|
images.set(cell, image);
|
||||||
|
}
|
||||||
|
return images;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueAt(
|
||||||
|
row: string[],
|
||||||
|
mapping: Map<(typeof PARTNER_BATCH_HEADERS)[number], number>,
|
||||||
|
header: (typeof PARTNER_BATCH_HEADERS)[number],
|
||||||
|
) {
|
||||||
|
const column = mapping.get(header);
|
||||||
|
return column === undefined ? "" : String(row[column] ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePartnerBatchWorkbook(input: ArrayBuffer | Uint8Array) {
|
||||||
|
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
|
||||||
|
if (bytes.byteLength > PARTNER_BATCH_MAX_BYTES) {
|
||||||
|
throw new Error("批量回填表不能超过80MB");
|
||||||
|
}
|
||||||
|
const entries = unzipSync(bytes);
|
||||||
|
const worksheetBytes = entries["xl/worksheets/sheet1.xml"];
|
||||||
|
if (!worksheetBytes) throw new Error("Excel 中没有可读取的批量回填工作表");
|
||||||
|
const sharedXml = entries["xl/sharedStrings.xml"]
|
||||||
|
? strFromU8(entries["xl/sharedStrings.xml"])
|
||||||
|
: "";
|
||||||
|
const sharedStrings = [
|
||||||
|
...sharedXml.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g),
|
||||||
|
].map((match) => textNodes(match[1]));
|
||||||
|
const rows = parseWorksheet(strFromU8(worksheetBytes), sharedStrings);
|
||||||
|
const header = findHeader(rows);
|
||||||
|
if (!header) {
|
||||||
|
throw new Error("表格结构不正确,请使用本领取页面导出的批量回填表");
|
||||||
|
}
|
||||||
|
const images = parseImages(entries);
|
||||||
|
const publishScreenshotColumn = header.mapping.get("笔记截图")!;
|
||||||
|
const creatorScreenshotColumn = header.mapping.get(
|
||||||
|
"数据分析截图(单篇笔记数据分析截图)",
|
||||||
|
)!;
|
||||||
|
const result: PartnerBatchImportRow[] = [];
|
||||||
|
for (let index = header.rowIndex + 1; index < rows.length; index += 1) {
|
||||||
|
const row = rows[index];
|
||||||
|
const distributionId = valueAt(row, header.mapping, "_系统笔记ID");
|
||||||
|
if (!distributionId && !row.some((value) => String(value ?? "").trim())) continue;
|
||||||
|
result.push({
|
||||||
|
spreadsheetRow: index + 1,
|
||||||
|
sequence: valueAt(row, header.mapping, "序号(不能改)"),
|
||||||
|
title: valueAt(row, header.mapping, "标题"),
|
||||||
|
publishUrl: valueAt(row, header.mapping, "发布链接"),
|
||||||
|
distributionId,
|
||||||
|
originalPublishScreenshotKey: valueAt(
|
||||||
|
row,
|
||||||
|
header.mapping,
|
||||||
|
"_原笔记截图",
|
||||||
|
),
|
||||||
|
originalCreatorScreenshotKey: valueAt(
|
||||||
|
row,
|
||||||
|
header.mapping,
|
||||||
|
"_原数据分析截图",
|
||||||
|
),
|
||||||
|
publishScreenshot:
|
||||||
|
images.get(`${index + 1}:${publishScreenshotColumn}`) ?? null,
|
||||||
|
creatorScreenshot:
|
||||||
|
images.get(`${index + 1}:${creatorScreenshotColumn}`) ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (result.length === 0) throw new Error("表格中没有可回填的笔记");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -1,22 +1,31 @@
|
|||||||
import { hashText } from "./mvp-db";
|
import { hashText } from "./mvp-db";
|
||||||
import {
|
import {
|
||||||
|
extractPublishUrl,
|
||||||
extractXhsPublishUrl,
|
extractXhsPublishUrl,
|
||||||
|
platformFromPublishUrl,
|
||||||
safeHttpUrl,
|
safeHttpUrl,
|
||||||
|
type SupportedPlatform,
|
||||||
} from "./publish-url";
|
} from "./publish-url";
|
||||||
|
|
||||||
export { extractXhsPublishUrl } from "./publish-url";
|
export { extractXhsPublishUrl } from "./publish-url";
|
||||||
|
|
||||||
export function accountFromPublishLink(input: string) {
|
export function accountFromPublishLink(
|
||||||
const url = safeHttpUrl(extractXhsPublishUrl(input));
|
input: string,
|
||||||
|
expectedPlatform?: SupportedPlatform,
|
||||||
|
) {
|
||||||
|
const extracted = expectedPlatform
|
||||||
|
? extractPublishUrl(input, expectedPlatform)
|
||||||
|
: extractXhsPublishUrl(input) || extractPublishUrl(input, "抖音");
|
||||||
|
const url = safeHttpUrl(extracted);
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
const platform =
|
const platform = platformFromPublishUrl(url.toString());
|
||||||
url.hostname.includes("xiaohongshu") || url.hostname.includes("xhslink")
|
if (!platform || (expectedPlatform && platform !== expectedPlatform)) return null;
|
||||||
? "小红书"
|
|
||||||
: "其他平台";
|
|
||||||
const noteId =
|
const noteId =
|
||||||
url.pathname.match(
|
platform === "抖音"
|
||||||
/\/(?:discovery\/item|explore)\/([A-Za-z0-9_-]{8,80})/,
|
? url.pathname.match(/\/(?:video|note)\/([A-Za-z0-9_-]{8,80})/)?.[1] ?? ""
|
||||||
)?.[1] ?? "";
|
: url.pathname.match(
|
||||||
|
/\/(?:discovery\/item|explore)\/([A-Za-z0-9_-]{8,80})/,
|
||||||
|
)?.[1] ?? "";
|
||||||
const platformUid = `pending-${hashText(
|
const platformUid = `pending-${hashText(
|
||||||
noteId || `${url.origin}${url.pathname}`,
|
noteId || `${url.origin}${url.pathname}`,
|
||||||
)}`;
|
)}`;
|
||||||
|
|||||||
@@ -25,3 +25,44 @@ export function extractXhsPublishUrl(input: string) {
|
|||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SupportedPlatform = "小红书" | "抖音";
|
||||||
|
|
||||||
|
function platformMatches(url: URL, platform: SupportedPlatform) {
|
||||||
|
const hostname = url.hostname.toLowerCase();
|
||||||
|
if (platform === "抖音") {
|
||||||
|
return hostname === "douyin.com" || hostname.endsWith(".douyin.com");
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
hostname === "xiaohongshu.com" ||
|
||||||
|
hostname.endsWith(".xiaohongshu.com") ||
|
||||||
|
hostname === "xhslink.cn" ||
|
||||||
|
hostname.endsWith(".xhslink.cn")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractPublishUrl(
|
||||||
|
input: string,
|
||||||
|
platform: SupportedPlatform = "小红书",
|
||||||
|
) {
|
||||||
|
const candidates =
|
||||||
|
input.match(/https?:\/\/[^\s<>"',。!?;:、【】()]+/gi) ?? [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const cleaned = candidate.replace(/[.,!?;:~)\]}]+$/g, "");
|
||||||
|
const url = safeHttpUrl(cleaned);
|
||||||
|
if (url && platformMatches(url, platform)) return url.toString();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAnyPublishUrl(input: string) {
|
||||||
|
return extractPublishUrl(input, "小红书") || extractPublishUrl(input, "抖音");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function platformFromPublishUrl(input: string): SupportedPlatform | "" {
|
||||||
|
const url = safeHttpUrl(input);
|
||||||
|
if (!url) return "";
|
||||||
|
if (platformMatches(url, "小红书")) return "小红书";
|
||||||
|
if (platformMatches(url, "抖音")) return "抖音";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ export type RecoveryWorkbookRow = {
|
|||||||
images: Array<{
|
images: Array<{
|
||||||
column: number;
|
column: number;
|
||||||
image: RecoveryWorkbookImage;
|
image: RecoveryWorkbookImage;
|
||||||
|
offsetX?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
}>;
|
}>;
|
||||||
hyperlinks?: Array<{
|
hyperlinks?: Array<{
|
||||||
column: number;
|
column: number;
|
||||||
@@ -25,6 +28,7 @@ type WorkbookOptions = {
|
|||||||
headers: string[];
|
headers: string[];
|
||||||
columnWidths: number[];
|
columnWidths: number[];
|
||||||
rows: RecoveryWorkbookRow[];
|
rows: RecoveryWorkbookRow[];
|
||||||
|
hiddenColumns?: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const XML_HEADER = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
|
const XML_HEADER = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
|
||||||
@@ -130,9 +134,17 @@ function imageDimensions(image: RecoveryWorkbookImage) {
|
|||||||
return { width: 4, height: 3 };
|
return { width: 4, height: 3 };
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageDisplaySize(image: RecoveryWorkbookImage) {
|
function imageDisplaySize(
|
||||||
|
image: RecoveryWorkbookImage,
|
||||||
|
maxWidth = 160,
|
||||||
|
maxHeight = 150,
|
||||||
|
) {
|
||||||
const dimensions = imageDimensions(image);
|
const dimensions = imageDimensions(image);
|
||||||
const scale = Math.min(160 / dimensions.width, 150 / dimensions.height, 1);
|
const scale = Math.min(
|
||||||
|
maxWidth / dimensions.width,
|
||||||
|
maxHeight / dimensions.height,
|
||||||
|
1,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
width: Math.max(28, Math.round(dimensions.width * scale)),
|
width: Math.max(28, Math.round(dimensions.width * scale)),
|
||||||
height: Math.max(28, Math.round(dimensions.height * scale)),
|
height: Math.max(28, Math.round(dimensions.height * scale)),
|
||||||
@@ -152,6 +164,14 @@ export function buildRecoveryWorkbook(options: WorkbookOptions) {
|
|||||||
const imageEntries = options.rows.flatMap((row, rowIndex) =>
|
const imageEntries = options.rows.flatMap((row, rowIndex) =>
|
||||||
row.images.map((item) => ({ ...item, row: rowIndex + 1 })),
|
row.images.map((item) => ({ ...item, row: rowIndex + 1 })),
|
||||||
);
|
);
|
||||||
|
const imageCells = new Set<string>();
|
||||||
|
imageEntries.forEach((entry) => {
|
||||||
|
const key = `${entry.row}:${entry.column}`;
|
||||||
|
if (imageCells.has(key)) {
|
||||||
|
throw new Error("Excel 单元格内只能嵌入一张图片,请为每张图片分配独立列");
|
||||||
|
}
|
||||||
|
imageCells.add(key);
|
||||||
|
});
|
||||||
const hyperlinkEntries = options.rows.flatMap((row, rowIndex) =>
|
const hyperlinkEntries = options.rows.flatMap((row, rowIndex) =>
|
||||||
(row.hyperlinks ?? [])
|
(row.hyperlinks ?? [])
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
@@ -178,7 +198,7 @@ export function buildRecoveryWorkbook(options: WorkbookOptions) {
|
|||||||
const reference = `${columnName(columnIndex)}${number}`;
|
const reference = `${columnName(columnIndex)}${number}`;
|
||||||
const value = row.cells[columnIndex] ?? "";
|
const value = row.cells[columnIndex] ?? "";
|
||||||
if (imageColumns.has(columnIndex)) {
|
if (imageColumns.has(columnIndex)) {
|
||||||
return inlineCell(reference, value || "见图", 4);
|
return inlineCell(reference, value, 4);
|
||||||
}
|
}
|
||||||
return typeof value === "number"
|
return typeof value === "number"
|
||||||
? numberCell(reference, value, 3)
|
? numberCell(reference, value, 3)
|
||||||
@@ -196,17 +216,22 @@ export function buildRecoveryWorkbook(options: WorkbookOptions) {
|
|||||||
const columns = options.headers
|
const columns = options.headers
|
||||||
.map((_, index) => {
|
.map((_, index) => {
|
||||||
const width = Math.min(70, Math.max(8, options.columnWidths[index] ?? 14));
|
const width = Math.min(70, Math.max(8, options.columnWidths[index] ?? 14));
|
||||||
return `<col min="${index + 1}" max="${index + 1}" width="${width}" customWidth="1"/>`;
|
const hidden = options.hiddenColumns?.includes(index) ? ' hidden="1"' : "";
|
||||||
|
return `<col min="${index + 1}" max="${index + 1}" width="${width}" customWidth="1"${hidden}/>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
const drawingXml = imageEntries.length
|
const drawingXml = imageEntries.length
|
||||||
? `${XML_HEADER}<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${imageEntries
|
? `${XML_HEADER}<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">${imageEntries
|
||||||
.map((entry, index) => {
|
.map((entry, index) => {
|
||||||
const size = imageDisplaySize(entry.image);
|
const size = imageDisplaySize(
|
||||||
const width = size.width * 9525;
|
entry.image,
|
||||||
const height = size.height * 9525;
|
entry.maxWidth ?? 160,
|
||||||
return `<xdr:oneCellAnchor><xdr:from><xdr:col>${entry.column}</xdr:col><xdr:colOff>57150</xdr:colOff><xdr:row>${entry.row}</xdr:row><xdr:rowOff>57150</xdr:rowOff></xdr:from><xdr:ext cx="${width}" cy="${height}"/><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="${index + 1}" name="${cleanXmlText(entry.image.description || `图片${index + 1}`)}"/><xdr:cNvPicPr><a:picLocks noChangeAspect="1"/></xdr:cNvPicPr></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="rId${index + 1}"/><a:stretch><a:fillRect/></a:stretch></xdr:blipFill><xdr:spPr><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr></xdr:pic><xdr:clientData/></xdr:oneCellAnchor>`;
|
entry.maxHeight ?? 150,
|
||||||
|
);
|
||||||
|
const offsetX = entry.offsetX ?? 6;
|
||||||
|
const offsetY = 6;
|
||||||
|
return `<xdr:twoCellAnchor editAs="twoCell"><xdr:from><xdr:col>${entry.column}</xdr:col><xdr:colOff>${offsetX * 9525}</xdr:colOff><xdr:row>${entry.row}</xdr:row><xdr:rowOff>${offsetY * 9525}</xdr:rowOff></xdr:from><xdr:to><xdr:col>${entry.column}</xdr:col><xdr:colOff>${(offsetX + size.width) * 9525}</xdr:colOff><xdr:row>${entry.row}</xdr:row><xdr:rowOff>${(offsetY + size.height) * 9525}</xdr:rowOff></xdr:to><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="${index + 1}" name="${cleanXmlText(entry.image.description || `图片${index + 1}`)}"/><xdr:cNvPicPr><a:picLocks noChangeAspect="1"/></xdr:cNvPicPr></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="rId${index + 1}"/><a:stretch><a:fillRect/></a:stretch></xdr:blipFill><xdr:spPr><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></xdr:spPr></xdr:pic><xdr:clientData/></xdr:twoCellAnchor>`;
|
||||||
})
|
})
|
||||||
.join("")}</xdr:wsDr>`
|
.join("")}</xdr:wsDr>`
|
||||||
: "";
|
: "";
|
||||||
@@ -227,7 +252,10 @@ export function buildRecoveryWorkbook(options: WorkbookOptions) {
|
|||||||
const imageContentTypes = [...imageFormats.entries()]
|
const imageContentTypes = [...imageFormats.entries()]
|
||||||
.map(([extension, contentType]) => `<Default Extension="${extension}" ContentType="${contentType}"/>`)
|
.map(([extension, contentType]) => `<Default Extension="${extension}" ContentType="${contentType}"/>`)
|
||||||
.join("");
|
.join("");
|
||||||
const contentTypes = `${XML_HEADER}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>${imageContentTypes}<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${imageEntries.length ? '<Override PartName="/xl/drawings/drawing1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>' : ""}<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>`;
|
const drawingContentType = imageEntries.length
|
||||||
|
? '<Override PartName="/xl/drawings/drawing1.xml" ContentType="application/vnd.openxmlformats-officedocument.drawing+xml"/>'
|
||||||
|
: "";
|
||||||
|
const contentTypes = `${XML_HEADER}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>${imageContentTypes}<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${drawingContentType}<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/><Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/></Types>`;
|
||||||
const hyperlinkRelationshipOffset = imageEntries.length ? 2 : 1;
|
const hyperlinkRelationshipOffset = imageEntries.length ? 2 : 1;
|
||||||
const hyperlinksXml = hyperlinkEntries.length
|
const hyperlinksXml = hyperlinkEntries.length
|
||||||
? `<hyperlinks>${hyperlinkEntries
|
? `<hyperlinks>${hyperlinkEntries
|
||||||
@@ -266,7 +294,9 @@ export function buildRecoveryWorkbook(options: WorkbookOptions) {
|
|||||||
}
|
}
|
||||||
if (imageEntries.length) {
|
if (imageEntries.length) {
|
||||||
files["xl/drawings/drawing1.xml"] = strToU8(drawingXml);
|
files["xl/drawings/drawing1.xml"] = strToU8(drawingXml);
|
||||||
files["xl/drawings/_rels/drawing1.xml.rels"] = strToU8(drawingRelationships);
|
files["xl/drawings/_rels/drawing1.xml.rels"] = strToU8(
|
||||||
|
drawingRelationships,
|
||||||
|
);
|
||||||
imageEntries.forEach((entry, index) => {
|
imageEntries.forEach((entry, index) => {
|
||||||
const format = imageFormat(entry.image.contentType, entry.image.bytes);
|
const format = imageFormat(entry.image.contentType, entry.image.bytes);
|
||||||
files[`xl/media/image${index + 1}.${format.extension}`] = entry.image.bytes;
|
files[`xl/media/image${index + 1}.${format.extension}`] = entry.image.bytes;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { strFromU8, unzipSync } from "fflate";
|
import { strFromU8, unzipSync } from "fflate";
|
||||||
|
|
||||||
export const RESOURCE_IMPORT_MAX_ROWS = 100;
|
export const RESOURCE_IMPORT_MAX_ROWS = 10_000;
|
||||||
export const RESOURCE_IMPORT_MAX_BYTES = 5 * 1024 * 1024;
|
export const RESOURCE_IMPORT_MAX_BYTES = 20 * 1024 * 1024;
|
||||||
|
|
||||||
export type ResourceImportRow = {
|
export type ResourceImportRow = {
|
||||||
rowNumber: number;
|
rowNumber: number;
|
||||||
@@ -12,8 +12,10 @@ export type ResourceImportRow = {
|
|||||||
ipLocation: string;
|
ipLocation: string;
|
||||||
followers: number;
|
followers: number;
|
||||||
followersResolved: boolean;
|
followersResolved: boolean;
|
||||||
|
gender: "" | "男" | "女";
|
||||||
|
bio: string;
|
||||||
|
tags: string[];
|
||||||
cooperationSource: string;
|
cooperationSource: string;
|
||||||
tags: string;
|
|
||||||
errors: string[];
|
errors: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,8 +25,10 @@ const HEADER_ALIASES = {
|
|||||||
publicAccountId: ["账号ID", "账号号", "小红书号", "抖音号"],
|
publicAccountId: ["账号ID", "账号号", "小红书号", "抖音号"],
|
||||||
ipLocation: ["IP属地", "IP地址", "IP地区", "IP所在地"],
|
ipLocation: ["IP属地", "IP地址", "IP地区", "IP所在地"],
|
||||||
followers: ["粉丝数", "粉丝", "粉丝量"],
|
followers: ["粉丝数", "粉丝", "粉丝量"],
|
||||||
|
gender: ["性别"],
|
||||||
|
bio: ["简介", "账号简介", "个人简介"],
|
||||||
|
tags: ["标签", "账号标签"],
|
||||||
cooperationSource: ["合作来源", "资源来源", "历史合作来源"],
|
cooperationSource: ["合作来源", "资源来源", "历史合作来源"],
|
||||||
tags: ["标签", "账号标签", "人设标签"],
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type CanonicalHeader = keyof typeof HEADER_ALIASES;
|
type CanonicalHeader = keyof typeof HEADER_ALIASES;
|
||||||
@@ -187,6 +191,15 @@ export function platformFromProfileUrl(profileUrl: string) {
|
|||||||
) {
|
) {
|
||||||
return "小红书";
|
return "小红书";
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
((url.hostname === "douyin.com" || url.hostname.endsWith(".douyin.com")) &&
|
||||||
|
/^\/user\/[^/]+/i.test(url.pathname)) ||
|
||||||
|
((url.hostname === "iesdouyin.com" ||
|
||||||
|
url.hostname.endsWith(".iesdouyin.com")) &&
|
||||||
|
/^\/share\/user\/[^/]+/i.test(url.pathname))
|
||||||
|
) {
|
||||||
|
return "抖音";
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// URL validation is reported by normalizeRows.
|
// URL validation is reported by normalizeRows.
|
||||||
}
|
}
|
||||||
@@ -216,10 +229,42 @@ export function parseResourceFollowers(value: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeResourceGender(value: string) {
|
||||||
|
const normalized = value.trim().toLocaleLowerCase("zh-CN");
|
||||||
|
if (!normalized || ["未知", "未填写", "待识别", "unknown"].includes(normalized)) {
|
||||||
|
return { value: "" as const, valid: true };
|
||||||
|
}
|
||||||
|
if (["男", "男性", "male", "m"].includes(normalized)) {
|
||||||
|
return { value: "男" as const, valid: true };
|
||||||
|
}
|
||||||
|
if (["女", "女性", "female", "f"].includes(normalized)) {
|
||||||
|
return { value: "女" as const, valid: true };
|
||||||
|
}
|
||||||
|
return { value: "" as const, valid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeResourceTags(value: string | string[]) {
|
||||||
|
const source = Array.isArray(value) ? value.join(",") : value;
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
source
|
||||||
|
.split(/[,,、;;|]/)
|
||||||
|
.map((item) => item.trim().replace(/^#+/, ""))
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function resourceImportMissingFields(
|
export function resourceImportMissingFields(
|
||||||
row: Pick<
|
row: Pick<
|
||||||
ResourceImportRow,
|
ResourceImportRow,
|
||||||
"nickname" | "publicAccountId" | "ipLocation" | "followersResolved"
|
| "nickname"
|
||||||
|
| "publicAccountId"
|
||||||
|
| "ipLocation"
|
||||||
|
| "followersResolved"
|
||||||
|
| "gender"
|
||||||
|
| "bio"
|
||||||
|
| "tags"
|
||||||
>,
|
>,
|
||||||
) {
|
) {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
@@ -229,6 +274,8 @@ export function resourceImportMissingFields(
|
|||||||
missing.push("ipLocation");
|
missing.push("ipLocation");
|
||||||
}
|
}
|
||||||
if (!row.followersResolved) missing.push("followers");
|
if (!row.followersResolved) missing.push("followers");
|
||||||
|
if (!row.gender) missing.push("gender");
|
||||||
|
if (!row.bio.trim()) missing.push("bio");
|
||||||
return missing;
|
return missing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,24 +293,40 @@ function normalizeRows(rows: string[][]) {
|
|||||||
const platform = platformFromProfileUrl(profileUrl);
|
const platform = platformFromProfileUrl(profileUrl);
|
||||||
const rawFollowers = valueAt(source, header.mapping, "followers");
|
const rawFollowers = valueAt(source, header.mapping, "followers");
|
||||||
const parsedFollowers = parseResourceFollowers(rawFollowers);
|
const parsedFollowers = parseResourceFollowers(rawFollowers);
|
||||||
|
const parsedGender = normalizeResourceGender(
|
||||||
|
valueAt(source, header.mapping, "gender"),
|
||||||
|
);
|
||||||
|
const tags = normalizeResourceTags(valueAt(source, header.mapping, "tags"));
|
||||||
|
const ipLocation = valueAt(source, header.mapping, "ipLocation");
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
if (!rawProfileUrl) errors.push("账号主页不能为空");
|
if (!rawProfileUrl) errors.push("账号主页不能为空");
|
||||||
else if (!profileUrl) errors.push("账号主页链接格式不正确");
|
else if (!profileUrl) errors.push("账号主页链接格式不正确");
|
||||||
else if (!platform) errors.push("当前自动解析仅支持小红书账号主页");
|
else if (!platform) errors.push("当前自动解析仅支持小红书或抖音账号主页");
|
||||||
if (!parsedFollowers.valid) {
|
if (!parsedFollowers.valid) {
|
||||||
errors.push("粉丝数格式不正确,请填写数字或如 1.3万、10+");
|
errors.push("粉丝数格式不正确,请填写数字或如 1.3万、10+");
|
||||||
}
|
}
|
||||||
|
if (!parsedGender.valid) {
|
||||||
|
errors.push("性别格式不正确,请填写男、女或留空");
|
||||||
|
}
|
||||||
|
if (tags.length > 5) {
|
||||||
|
errors.push("单个账号最多填写 5 个标签,请用逗号分隔");
|
||||||
|
}
|
||||||
|
if (/^\d+$/.test(ipLocation)) {
|
||||||
|
errors.push("IP属地格式不正确,请填写省份、地区或国家名称");
|
||||||
|
}
|
||||||
result.push({
|
result.push({
|
||||||
rowNumber: index + 1,
|
rowNumber: index + 1,
|
||||||
platform,
|
platform,
|
||||||
nickname: valueAt(source, header.mapping, "nickname"),
|
nickname: valueAt(source, header.mapping, "nickname"),
|
||||||
publicAccountId: valueAt(source, header.mapping, "publicAccountId"),
|
publicAccountId: valueAt(source, header.mapping, "publicAccountId"),
|
||||||
profileUrl,
|
profileUrl,
|
||||||
ipLocation: valueAt(source, header.mapping, "ipLocation"),
|
ipLocation,
|
||||||
followers: parsedFollowers.value,
|
followers: parsedFollowers.value,
|
||||||
followersResolved: parsedFollowers.resolved,
|
followersResolved: parsedFollowers.resolved,
|
||||||
|
gender: parsedGender.value,
|
||||||
|
bio: valueAt(source, header.mapping, "bio"),
|
||||||
|
tags: tags.slice(0, 5),
|
||||||
cooperationSource: valueAt(source, header.mapping, "cooperationSource"),
|
cooperationSource: valueAt(source, header.mapping, "cooperationSource"),
|
||||||
tags: normalizeTags(valueAt(source, header.mapping, "tags")),
|
|
||||||
errors,
|
errors,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -325,18 +388,3 @@ export function mergeCooperationSources(existing: string, incoming: string) {
|
|||||||
),
|
),
|
||||||
].join("、");
|
].join("、");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeTags(value: string) {
|
|
||||||
return [
|
|
||||||
...new Set(
|
|
||||||
value
|
|
||||||
.split(/[、,,;;|]/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
),
|
|
||||||
].join("、");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergeTags(existing: string, incoming: string) {
|
|
||||||
return normalizeTags(`${existing}、${incoming}`);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export type CreateDistributionTaskInput = {
|
|||||||
name: string;
|
name: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
dueAt: string;
|
dueAt: string;
|
||||||
|
platform?: "小红书" | "抖音";
|
||||||
|
contentFormat?: "image_text" | "video";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateScreenshotTaskInput = {
|
export type CreateScreenshotTaskInput = {
|
||||||
@@ -33,6 +35,8 @@ export type DistributionTaskCreation = {
|
|||||||
sheetId: string;
|
sheetId: string;
|
||||||
sheetName: string;
|
sheetName: string;
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
|
platform: "小红书" | "抖音";
|
||||||
|
contentFormat: "image_text" | "video";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ScreenshotTaskCreation = {
|
export type ScreenshotTaskCreation = {
|
||||||
@@ -55,6 +59,8 @@ type TaskRow = {
|
|||||||
source_url: string;
|
source_url: string;
|
||||||
source_sheet_id: string;
|
source_sheet_id: string;
|
||||||
source_sheet_name: string;
|
source_sheet_name: string;
|
||||||
|
platform: "小红书" | "抖音";
|
||||||
|
content_format: "image_text" | "video";
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizedValue(value: string) {
|
function normalizedValue(value: string) {
|
||||||
@@ -99,17 +105,27 @@ async function findExistingTask(
|
|||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id, share_token, name, brand, due_at, quantity,
|
`SELECT id, share_token, name, brand, due_at, quantity,
|
||||||
source_url, source_sheet_id, source_sheet_name
|
source_url, source_sheet_id, source_sheet_name,
|
||||||
|
platform, content_format
|
||||||
FROM tasks
|
FROM tasks
|
||||||
WHERE name = ?
|
WHERE name = ?
|
||||||
AND brand = ?
|
AND brand = ?
|
||||||
AND due_at = ?
|
AND due_at = ?
|
||||||
AND source_url = ?
|
AND source_url = ?
|
||||||
|
AND platform = ?
|
||||||
|
AND content_format = ?
|
||||||
AND status IN ('active', 'importing')
|
AND status IN ('active', 'importing')
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
)
|
)
|
||||||
.bind(input.name, input.brand, input.dueAt, sourceUrl)
|
.bind(
|
||||||
|
input.name,
|
||||||
|
input.brand,
|
||||||
|
input.dueAt,
|
||||||
|
sourceUrl,
|
||||||
|
input.platform ?? "小红书",
|
||||||
|
input.contentFormat ?? "image_text",
|
||||||
|
)
|
||||||
.first<TaskRow>();
|
.first<TaskRow>();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,9 +140,10 @@ async function insertTaskFromSource(
|
|||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO tasks
|
`INSERT INTO tasks
|
||||||
(id, name, brand, quantity, claimed_quantity, due_at, status,
|
(id, name, brand, quantity, claimed_quantity, due_at, status,
|
||||||
|
platform, content_format,
|
||||||
source_url, source_sheet_id, source_sheet_name, source_synced_at,
|
source_url, source_sheet_id, source_sheet_name, source_synced_at,
|
||||||
share_token)
|
share_token)
|
||||||
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, 0, ?, 'importing', ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
taskId,
|
taskId,
|
||||||
@@ -134,6 +151,8 @@ async function insertTaskFromSource(
|
|||||||
input.brand,
|
input.brand,
|
||||||
source.rows.length,
|
source.rows.length,
|
||||||
input.dueAt,
|
input.dueAt,
|
||||||
|
input.platform ?? "小红书",
|
||||||
|
input.contentFormat ?? "image_text",
|
||||||
source.url,
|
source.url,
|
||||||
source.sheetId,
|
source.sheetId,
|
||||||
source.sheetName,
|
source.sheetName,
|
||||||
@@ -149,11 +168,15 @@ async function insertTaskFromSource(
|
|||||||
...image,
|
...image,
|
||||||
key: `content-assets/${taskId}/${contentId}/${image.index}`,
|
key: `content-assets/${taskId}/${contentId}/${image.index}`,
|
||||||
}));
|
}));
|
||||||
|
const videoAssets = row.videos.map((video) => ({
|
||||||
|
...video,
|
||||||
|
key: `content-videos/${taskId}/${contentId}/${video.index}`,
|
||||||
|
}));
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO contents
|
`INSERT INTO contents
|
||||||
(id, task_id, title, body, image_assets, status, source, source_row)
|
(id, task_id, title, body, image_assets, video_assets, status, source, source_row)
|
||||||
VALUES (?, ?, ?, ?, ?, 'available', ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, 'available', ?, ?)`,
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
contentId,
|
contentId,
|
||||||
@@ -161,6 +184,7 @@ async function insertTaskFromSource(
|
|||||||
row.title,
|
row.title,
|
||||||
row.body,
|
row.body,
|
||||||
JSON.stringify(imageAssets),
|
JSON.stringify(imageAssets),
|
||||||
|
JSON.stringify(videoAssets),
|
||||||
`飞书 · ${source.sheetName}`,
|
`飞书 · ${source.sheetName}`,
|
||||||
row.sourceRow,
|
row.sourceRow,
|
||||||
);
|
);
|
||||||
@@ -189,11 +213,13 @@ export async function createDistributionTask(
|
|||||||
options: { deduplicate?: boolean } = {},
|
options: { deduplicate?: boolean } = {},
|
||||||
): Promise<DistributionTaskCreation> {
|
): Promise<DistributionTaskCreation> {
|
||||||
await ensureSchema();
|
await ensureSchema();
|
||||||
const input = {
|
const input: Required<CreateDistributionTaskInput> = {
|
||||||
feishuUrl: normalizedFeishuUrl(rawInput.feishuUrl),
|
feishuUrl: normalizedFeishuUrl(rawInput.feishuUrl),
|
||||||
name: normalizedValue(rawInput.name),
|
name: normalizedValue(rawInput.name),
|
||||||
brand: normalizedValue(rawInput.brand),
|
brand: normalizedValue(rawInput.brand),
|
||||||
dueAt: normalizedDueDate(rawInput.dueAt),
|
dueAt: normalizedDueDate(rawInput.dueAt),
|
||||||
|
platform: rawInput.platform === "抖音" ? "抖音" : "小红书",
|
||||||
|
contentFormat: rawInput.contentFormat === "video" ? "video" : "image_text",
|
||||||
};
|
};
|
||||||
if (!input.feishuUrl || !input.name || !input.brand) {
|
if (!input.feishuUrl || !input.name || !input.brand) {
|
||||||
throw new Error("请补全飞书链接、任务名称和品牌/项目");
|
throw new Error("请补全飞书链接、任务名称和品牌/项目");
|
||||||
@@ -213,11 +239,19 @@ export async function createDistributionTask(
|
|||||||
sheetId: existing.source_sheet_id,
|
sheetId: existing.source_sheet_id,
|
||||||
sheetName: existing.source_sheet_name,
|
sheetName: existing.source_sheet_name,
|
||||||
sourceUrl: existing.source_url,
|
sourceUrl: existing.source_url,
|
||||||
|
platform: existing.platform,
|
||||||
|
contentFormat: existing.content_format,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const source = await readFeishuSource(input.feishuUrl, bindings);
|
const source = await readFeishuSource(input.feishuUrl, bindings);
|
||||||
|
if (
|
||||||
|
input.contentFormat === "video" &&
|
||||||
|
source.rows.some((row) => row.videos.length === 0)
|
||||||
|
) {
|
||||||
|
throw new Error("视频任务中存在未识别到视频的内容行,请检查飞书“视频”列");
|
||||||
|
}
|
||||||
const inserted = await insertTaskFromSource(source, input);
|
const inserted = await insertTaskFromSource(source, input);
|
||||||
return {
|
return {
|
||||||
created: true,
|
created: true,
|
||||||
@@ -230,6 +264,8 @@ export async function createDistributionTask(
|
|||||||
sheetId: source.sheetId,
|
sheetId: source.sheetId,
|
||||||
sheetName: source.sheetName,
|
sheetName: source.sheetName,
|
||||||
sourceUrl: source.url,
|
sourceUrl: source.url,
|
||||||
|
platform: input.platform,
|
||||||
|
contentFormat: input.contentFormat,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,8 +323,8 @@ export async function createScreenshotTask(
|
|||||||
db
|
db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO contents
|
`INSERT INTO contents
|
||||||
(id, task_id, title, body, image_assets, status, source, source_row)
|
(id, task_id, title, body, image_assets, video_assets, status, source, source_row)
|
||||||
VALUES (?, ?, ?, ?, ?, 'available', '截图回收任务', ?)`,
|
VALUES (?, ?, ?, ?, ?, '[]', 'available', '截图回收任务', ?)`,
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
uid("content"),
|
uid("content"),
|
||||||
|
|||||||
45
lib/video-file.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
const MP4_BRANDS = new Set([
|
||||||
|
"avc1",
|
||||||
|
"dash",
|
||||||
|
"isom",
|
||||||
|
"M4A ",
|
||||||
|
"M4B ",
|
||||||
|
"M4P ",
|
||||||
|
"M4V ",
|
||||||
|
"mp41",
|
||||||
|
"mp42",
|
||||||
|
"MSNV",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function fourCharacters(bytes: Uint8Array, offset: number) {
|
||||||
|
return String.fromCharCode(...bytes.slice(offset, offset + 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMp4Brand(brand: string) {
|
||||||
|
return (
|
||||||
|
MP4_BRANDS.has(brand) ||
|
||||||
|
/^iso[2-9]$/.test(brand) ||
|
||||||
|
/^3g[2p]$/.test(brand.slice(0, 3))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rejects HTML/JSON/error payloads and non-MP4 containers before download. */
|
||||||
|
export function hasMp4FileSignature(input: ArrayBuffer | Uint8Array) {
|
||||||
|
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
|
||||||
|
if (bytes.byteLength < 12 || fourCharacters(bytes, 4) !== "ftyp") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const declaredSize = new DataView(
|
||||||
|
bytes.buffer,
|
||||||
|
bytes.byteOffset,
|
||||||
|
bytes.byteLength,
|
||||||
|
).getUint32(0);
|
||||||
|
const boxEnd = Math.min(
|
||||||
|
bytes.byteLength,
|
||||||
|
declaredSize >= 12 ? declaredSize : bytes.byteLength,
|
||||||
|
);
|
||||||
|
for (let offset = 8; offset + 4 <= boxEnd; offset += 4) {
|
||||||
|
if (isMp4Brand(fourCharacters(bytes, offset))) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
83
lib/workbook-image.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
export type WorkbookSourceImage = {
|
||||||
|
bytes: Uint8Array;
|
||||||
|
contentType: string;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkbookImageNormalizationOptions = {
|
||||||
|
maxDimension?: number;
|
||||||
|
outputFormat?: "png" | "jpeg";
|
||||||
|
jpegQuality?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NORMALIZABLE_IMAGE = /^image\/(?:jpe?g|png|webp|gif|tiff?|avif|heic|heif)$/i;
|
||||||
|
|
||||||
|
function hasImageSignature(bytes: Uint8Array) {
|
||||||
|
if (bytes.length < 4) return false;
|
||||||
|
if (bytes[0] === 0xff && bytes[1] === 0xd8) return true;
|
||||||
|
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return true;
|
||||||
|
if (
|
||||||
|
bytes.length >= 12 &&
|
||||||
|
String.fromCharCode(...bytes.slice(0, 4)) === "RIFF" &&
|
||||||
|
String.fromCharCode(...bytes.slice(8, 12)) === "WEBP"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
(bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00) ||
|
||||||
|
(bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Excel viewers disagree on whether JPEG EXIF orientation should be applied.
|
||||||
|
* Bake that orientation into the pixels before the image enters the workbook.
|
||||||
|
* Callers may also resize and encode large source images as JPEG to keep the
|
||||||
|
* generated workbook within the upload limit.
|
||||||
|
*/
|
||||||
|
export async function normalizeWorkbookImage(
|
||||||
|
image: WorkbookSourceImage,
|
||||||
|
options: WorkbookImageNormalizationOptions = {},
|
||||||
|
): Promise<WorkbookSourceImage> {
|
||||||
|
if (!NORMALIZABLE_IMAGE.test(image.contentType) && !hasImageSignature(image.bytes)) {
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let normalized = sharp(image.bytes, { animated: false }).rotate();
|
||||||
|
if (options.maxDimension && options.maxDimension > 0) {
|
||||||
|
normalized = normalized.resize({
|
||||||
|
width: Math.floor(options.maxDimension),
|
||||||
|
height: Math.floor(options.maxDimension),
|
||||||
|
fit: "inside",
|
||||||
|
withoutEnlargement: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
normalized =
|
||||||
|
options.outputFormat === "jpeg"
|
||||||
|
? normalized
|
||||||
|
.flatten({ background: "#ffffff" })
|
||||||
|
.jpeg({
|
||||||
|
quality: Math.min(95, Math.max(50, options.jpegQuality ?? 82)),
|
||||||
|
mozjpeg: true,
|
||||||
|
})
|
||||||
|
: normalized.png({ compressionLevel: 6 });
|
||||||
|
const { data, info } = await normalized.toBuffer({ resolveWithObject: true });
|
||||||
|
return {
|
||||||
|
...image,
|
||||||
|
bytes: new Uint8Array(data),
|
||||||
|
contentType:
|
||||||
|
options.outputFormat === "jpeg" ? "image/jpeg" : "image/png",
|
||||||
|
width: info.width,
|
||||||
|
height: info.height,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,9 @@ CREATE TABLE IF NOT EXISTS accounts (
|
|||||||
profile_url TEXT NOT NULL DEFAULT (''),
|
profile_url TEXT NOT NULL DEFAULT (''),
|
||||||
ip_location VARCHAR(255) NOT NULL DEFAULT '待识别',
|
ip_location VARCHAR(255) NOT NULL DEFAULT '待识别',
|
||||||
followers INT NOT NULL DEFAULT 0,
|
followers INT NOT NULL DEFAULT 0,
|
||||||
|
gender VARCHAR(16) NOT NULL DEFAULT '',
|
||||||
|
bio TEXT NOT NULL DEFAULT (''),
|
||||||
|
tags VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
post_count INT NOT NULL DEFAULT 0,
|
post_count INT NOT NULL DEFAULT 0,
|
||||||
avg_views INT NOT NULL DEFAULT 0,
|
avg_views INT NOT NULL DEFAULT 0,
|
||||||
first_seen_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
first_seen_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE accounts
|
|
||||||
ADD COLUMN tags VARCHAR(500) NOT NULL DEFAULT '' AFTER cooperation_source;
|
|
||||||
14
mysql/0005_platform_video_douyin.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN platform VARCHAR(32) NOT NULL DEFAULT '小红书' AFTER task_type;
|
||||||
|
-- statement-breakpoint
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN content_format VARCHAR(32) NOT NULL DEFAULT 'image_text' AFTER platform;
|
||||||
|
-- statement-breakpoint
|
||||||
|
ALTER TABLE contents
|
||||||
|
ADD COLUMN video_assets LONGTEXT NOT NULL DEFAULT ('[]') AFTER image_assets;
|
||||||
|
-- statement-breakpoint
|
||||||
|
ALTER TABLE distributions
|
||||||
|
ADD COLUMN latest_shares INT NULL AFTER latest_collects;
|
||||||
|
-- statement-breakpoint
|
||||||
|
ALTER TABLE collection_runs
|
||||||
|
ADD COLUMN shares INT NULL AFTER collects;
|
||||||
8
mysql/0006_account_profile_tags.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE accounts
|
||||||
|
ADD COLUMN gender VARCHAR(16) NOT NULL DEFAULT '' AFTER followers;
|
||||||
|
-- statement-breakpoint
|
||||||
|
ALTER TABLE accounts
|
||||||
|
ADD COLUMN bio TEXT NOT NULL DEFAULT ('') AFTER gender;
|
||||||
|
-- statement-breakpoint
|
||||||
|
ALTER TABLE accounts
|
||||||
|
ADD COLUMN tags VARCHAR(500) NOT NULL DEFAULT '' AFTER bio;
|
||||||
2
mysql/0007_account_current_contact.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE accounts
|
||||||
|
ADD COLUMN current_contact VARCHAR(255) NOT NULL DEFAULT '' AFTER cooperation_source;
|
||||||
@@ -2,8 +2,7 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
serverExternalPackages: ["mysql2"],
|
serverExternalPackages: ["mysql2", "sharp"],
|
||||||
allowedDevOrigins: ["192.168.30.90"],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
5
package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-dom": "19.2.6",
|
"react-dom": "19.2.6",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1420,7 +1421,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -3836,7 +3836,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -7492,7 +7491,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/colour": "^1.1.0",
|
"@img/colour": "^1.1.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
@@ -7542,7 +7540,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"test": "npm run build && node --import tsx --test tests/*.test.mjs",
|
"test": "npm run build && node --import tsx --test tests/*.test.mjs",
|
||||||
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next --ignore-pattern koc-portal/out",
|
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next --ignore-pattern koc-portal/out",
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-dom": "19.2.6",
|
"react-dom": "19.2.6",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
7
scripts/setup.sh
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
APP_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
cd "$APP_DIR"
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
8
scripts/start.sh
Executable file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
APP_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
cd "$APP_DIR"
|
||||||
|
export HOSTNAME=0.0.0.0
|
||||||
|
export PORT="${PORT:-9000}"
|
||||||
|
exec node .next/standalone/server.js
|
||||||
@@ -120,6 +120,55 @@ test("resolves a wiki sheet and imports title, body, tags, and all images", asyn
|
|||||||
assert.equal(valuesCall.url.searchParams.get("ranges"), "sheet-one!A1:J20");
|
assert.equal(valuesCall.url.searchParams.get("ranges"), "sheet-one!A1:J20");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("imports video attachments from a Feishu video task sheet", async () => {
|
||||||
|
clearFeishuAccessTokenCacheForTests();
|
||||||
|
const { fetchImpl } = fakeFeishu();
|
||||||
|
const videoFetch = async (input, init = {}) => {
|
||||||
|
const url = new URL(String(input));
|
||||||
|
if (url.pathname.endsWith("/values_batch_get")) {
|
||||||
|
return apiResponse({
|
||||||
|
valueRanges: [
|
||||||
|
{
|
||||||
|
values: [
|
||||||
|
["标题", "内容(标题+正文+tag)", "视频"],
|
||||||
|
[
|
||||||
|
"一条测试视频",
|
||||||
|
"一条测试视频\n视频正文 #测试",
|
||||||
|
{
|
||||||
|
type: "attachment",
|
||||||
|
fileToken: "video-token-one",
|
||||||
|
text: "demo.mp4",
|
||||||
|
mimeType: "video/mp4",
|
||||||
|
size: 1024,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fetchImpl(input, init);
|
||||||
|
};
|
||||||
|
const source = await readFeishuSource(
|
||||||
|
"https://tenant.feishu.cn/wiki/wiki-test",
|
||||||
|
bindings,
|
||||||
|
videoFetch,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(source.rows.length, 1);
|
||||||
|
assert.equal(source.rows[0].title, "一条测试视频");
|
||||||
|
assert.equal(source.rows[0].body, "一条测试视频\n视频正文 #测试");
|
||||||
|
assert.deepEqual(source.rows[0].videos, [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
fileToken: "video-token-one",
|
||||||
|
name: "demo.mp4",
|
||||||
|
mimeType: "video/mp4",
|
||||||
|
size: 1024,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("requires an exact sheet link when a workbook has multiple visible sheets", async () => {
|
test("requires an exact sheet link when a workbook has multiple visible sheets", async () => {
|
||||||
clearFeishuAccessTokenCacheForTests();
|
clearFeishuAccessTokenCacheForTests();
|
||||||
const { fetchImpl } = fakeFeishu({
|
const { fetchImpl } = fakeFeishu({
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import {
|
import {
|
||||||
|
collectMetricsFromMcp,
|
||||||
collectXhsMetricsFromMcp,
|
collectXhsMetricsFromMcp,
|
||||||
|
resolveAccountProfileFromMcp,
|
||||||
resolveCollectionMcpConfig,
|
resolveCollectionMcpConfig,
|
||||||
resolveXhsPublicAccountDetails,
|
resolveXhsPublicAccountDetails,
|
||||||
resolveXhsPublicAccountId,
|
resolveXhsPublicAccountId,
|
||||||
@@ -40,10 +42,17 @@ function toolEnvelope(payload, isError = false) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFakeMcp(toolResults) {
|
function createFakeMcp(toolResults, redirects = {}) {
|
||||||
let toolIndex = 0;
|
let toolIndex = 0;
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const fetchImpl = async (url, init) => {
|
const fetchImpl = async (url, init) => {
|
||||||
|
if (!init?.body) {
|
||||||
|
const requestUrl = String(url);
|
||||||
|
calls.push({ url: requestUrl, body: null, headers: new Headers(init?.headers) });
|
||||||
|
const location = redirects[requestUrl];
|
||||||
|
if (!location) throw new Error(`Unexpected public request: ${requestUrl}`);
|
||||||
|
return new Response("", { status: 302, headers: { location } });
|
||||||
|
}
|
||||||
const body = JSON.parse(init.body);
|
const body = JSON.parse(init.body);
|
||||||
calls.push({ url: String(url), body, headers: new Headers(init.headers) });
|
calls.push({ url: String(url), body, headers: new Headers(init.headers) });
|
||||||
if (body.method === "initialize") {
|
if (body.method === "initialize") {
|
||||||
@@ -103,6 +112,7 @@ test("collects likes, comments and favorites from the verified MCP shape", async
|
|||||||
likes: 483,
|
likes: 483,
|
||||||
comments: 41,
|
comments: 41,
|
||||||
collects: 519,
|
collects: 519,
|
||||||
|
shares: 0,
|
||||||
});
|
});
|
||||||
assert.equal(calls.length, 3);
|
assert.equal(calls.length, 3);
|
||||||
assert.equal(calls[2].body.params.name, "fetch_content_detail");
|
assert.equal(calls[2].body.params.name, "fetch_content_detail");
|
||||||
@@ -147,7 +157,7 @@ test("collects through a stateless MCP server without a session header", async (
|
|||||||
fetchImpl,
|
fetchImpl,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(result, { likes: 12, comments: 3, collects: 6 });
|
assert.deepEqual(result, { likes: 12, comments: 3, collects: 6, shares: 0 });
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
calls.map((call) => call.body.method),
|
calls.map((call) => call.body.method),
|
||||||
["initialize", "tools/call"],
|
["initialize", "tools/call"],
|
||||||
@@ -217,19 +227,24 @@ test("resolves the real XHS account profile from a submitted note link", async (
|
|||||||
redId: "94329495984",
|
redId: "94329495984",
|
||||||
ipLocation: "重庆",
|
ipLocation: "重庆",
|
||||||
followers: 734,
|
followers: 734,
|
||||||
|
gender: "",
|
||||||
|
bio: "",
|
||||||
|
recentNoteTitles: [],
|
||||||
|
providerTags: [],
|
||||||
});
|
});
|
||||||
assert.equal(calls.length, 4);
|
const mcpCalls = calls.filter((call) => call.body?.method);
|
||||||
|
assert.equal(mcpCalls.length, 4);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
calls[2].body.params.name,
|
mcpCalls[2].body.params.name,
|
||||||
"collect_xhs_wen_note_detail",
|
"fetch_content_detail",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
calls[2].body.params.arguments.request.note_id,
|
mcpCalls[2].body.params.arguments.request.link,
|
||||||
"6a671108000000000f004bef",
|
"https://www.xiaohongshu.com/discovery/item/6a671108000000000f004bef",
|
||||||
);
|
);
|
||||||
assert.equal(calls[3].body.params.name, "parse_xhs_user_summary");
|
assert.equal(mcpCalls[3].body.params.name, "parse_xhs_user_summary");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
calls[3].body.params.arguments.request.url,
|
mcpCalls[3].body.params.arguments.request.url,
|
||||||
"https://www.xiaohongshu.com/user/profile/6905cbca0000000037009f49",
|
"https://www.xiaohongshu.com/user/profile/6905cbca0000000037009f49",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -253,7 +268,17 @@ test("resolves followers directly from the supported XHS user summary tool", asy
|
|||||||
ipLocation: "福建",
|
ipLocation: "福建",
|
||||||
nickname: "555 五",
|
nickname: "555 五",
|
||||||
userId: "1020668113",
|
userId: "1020668113",
|
||||||
|
gender: "女",
|
||||||
|
desc: "分享城市周末与美食",
|
||||||
|
tags: ["本地生活"],
|
||||||
},
|
},
|
||||||
|
notes: [
|
||||||
|
{
|
||||||
|
note_id: "note-1",
|
||||||
|
title: "长沙湘菜探店",
|
||||||
|
url: "https://www.xiaohongshu.com/explore/note-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -273,6 +298,10 @@ test("resolves followers directly from the supported XHS user summary tool", asy
|
|||||||
followers: 6,
|
followers: 6,
|
||||||
redId: "1020668113",
|
redId: "1020668113",
|
||||||
ipLocation: "福建",
|
ipLocation: "福建",
|
||||||
|
gender: "女",
|
||||||
|
bio: "分享城市周末与美食",
|
||||||
|
recentNoteTitles: ["长沙湘菜探店"],
|
||||||
|
providerTags: ["本地生活"],
|
||||||
});
|
});
|
||||||
assert.equal(calls[2].body.params.name, "parse_xhs_user_summary");
|
assert.equal(calls[2].body.params.name, "parse_xhs_user_summary");
|
||||||
});
|
});
|
||||||
@@ -338,8 +367,8 @@ test("resolves an xhslink short URL before requesting the author profile", async
|
|||||||
"https://www.xiaohongshu.com/user/profile/6905cbca0000000037009f49",
|
"https://www.xiaohongshu.com/user/profile/6905cbca0000000037009f49",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
fakeMcp.calls[2].body.params.arguments.request.note_id,
|
fakeMcp.calls[2].body.params.arguments.request.link,
|
||||||
"6a572da40000000021018bd2",
|
"http://xhslink.cn/o/AJFyP5dnj7O",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -400,10 +429,14 @@ test("uses the public note author when MCP profile lookup fails", async () => {
|
|||||||
redId: "1020668113",
|
redId: "1020668113",
|
||||||
ipLocation: "待识别",
|
ipLocation: "待识别",
|
||||||
followers: 6,
|
followers: 6,
|
||||||
|
gender: "",
|
||||||
|
bio: "",
|
||||||
|
recentNoteTitles: [],
|
||||||
|
providerTags: [],
|
||||||
});
|
});
|
||||||
assert.equal(
|
assert.equal(
|
||||||
fakeMcp.calls[2].body.params.name,
|
fakeMcp.calls[2].body.params.name,
|
||||||
"collect_xhs_wen_note_detail",
|
"fetch_content_detail",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -431,27 +464,16 @@ test("reads the user-visible Xiaohongshu number from a public profile", async ()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("falls back to parse_xhs_note when the primary tool fails", async () => {
|
test("treats empty interaction counters from the current detail tool as zero", async () => {
|
||||||
const { calls, fetchImpl } = createFakeMcp([
|
const { calls, fetchImpl } = createFakeMcp([
|
||||||
toolEnvelope(
|
|
||||||
{
|
|
||||||
response: {
|
|
||||||
code: 400,
|
|
||||||
success: false,
|
|
||||||
msg: "获取内容详情失败",
|
|
||||||
data: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
toolEnvelope({
|
toolEnvelope({
|
||||||
response: {
|
response: {
|
||||||
code: 200,
|
code: 200,
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
likes: "1.2万",
|
likes: "2",
|
||||||
comments: 32,
|
comments: "",
|
||||||
collects: "2,345",
|
collects: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -466,11 +488,42 @@ test("falls back to parse_xhs_note when the primary tool fails", async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(result, {
|
assert.deepEqual(result, {
|
||||||
likes: 12_000,
|
likes: 2,
|
||||||
comments: 32,
|
comments: 0,
|
||||||
collects: 2_345,
|
collects: 0,
|
||||||
|
shares: 0,
|
||||||
});
|
});
|
||||||
assert.equal(calls[3].body.params.name, "parse_xhs_note");
|
assert.equal(calls.length, 3);
|
||||||
|
assert.equal(calls[2].body.params.name, "fetch_content_detail");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("surfaces current detail tool failures without calling removed tools", async () => {
|
||||||
|
const { calls, fetchImpl } = createFakeMcp([
|
||||||
|
toolEnvelope(
|
||||||
|
{
|
||||||
|
response: {
|
||||||
|
code: 400,
|
||||||
|
success: false,
|
||||||
|
msg: "获取内容详情失败",
|
||||||
|
data: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
collectXhsMetricsFromMcp(
|
||||||
|
"https://www.xiaohongshu.com/explore/test",
|
||||||
|
{
|
||||||
|
endpoint: "https://collector.example/mcp?key=test-key",
|
||||||
|
},
|
||||||
|
fetchImpl,
|
||||||
|
),
|
||||||
|
/获取内容详情失败/,
|
||||||
|
);
|
||||||
|
assert.equal(calls.length, 3);
|
||||||
|
assert.equal(calls[2].body.params.name, "fetch_content_detail");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("requires the MCP key without sending a network request", async () => {
|
test("requires the MCP key without sending a network request", async () => {
|
||||||
@@ -551,6 +604,237 @@ test("rebuilds the MCP session after a gateway session miss", async () => {
|
|||||||
fetchImpl,
|
fetchImpl,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(result, { likes: 8, comments: 2, collects: 5 });
|
assert.deepEqual(result, { likes: 8, comments: 2, collects: 5, shares: 0 });
|
||||||
assert.equal(initializeCount, 2);
|
assert.equal(initializeCount, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("collects Douyin likes, favorites, shares and comments", async () => {
|
||||||
|
const { calls, fetchImpl } = createFakeMcp([
|
||||||
|
toolEnvelope({
|
||||||
|
response: {
|
||||||
|
code: 200,
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
digg_count: "120",
|
||||||
|
collect_count: "30",
|
||||||
|
share_count: "8",
|
||||||
|
comment_count: "12",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await collectMetricsFromMcp(
|
||||||
|
"https://www.douyin.com/video/7520000000000000000",
|
||||||
|
"抖音",
|
||||||
|
{ endpoint: "https://collector.example/mcp", key: "test-key" },
|
||||||
|
fetchImpl,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
likes: 120,
|
||||||
|
collects: 30,
|
||||||
|
shares: 8,
|
||||||
|
comments: 12,
|
||||||
|
});
|
||||||
|
assert.equal(calls[2].body.params.name, "fetch_content_detail");
|
||||||
|
assert.equal(calls[2].body.params.arguments.request.plant, "dy");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps the current Douyin MCP metric field names", async () => {
|
||||||
|
const { fetchImpl } = createFakeMcp([
|
||||||
|
toolEnvelope({
|
||||||
|
response: {
|
||||||
|
code: 200,
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
liked_count: "5682",
|
||||||
|
collected_count: "565",
|
||||||
|
share_count: "6878",
|
||||||
|
comment_count: "332",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await collectMetricsFromMcp(
|
||||||
|
"https://v.douyin.com/5O5VpgomO2U/",
|
||||||
|
"抖音",
|
||||||
|
{ endpoint: "https://collector.example/mcp", key: "test-key" },
|
||||||
|
fetchImpl,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
likes: 5682,
|
||||||
|
collects: 565,
|
||||||
|
shares: 6878,
|
||||||
|
comments: 332,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves a Douyin account from a submitted work link", async () => {
|
||||||
|
const secUid = "MS4wLjABAAAA-test-profile-123456";
|
||||||
|
const { calls, fetchImpl } = createFakeMcp([
|
||||||
|
toolEnvelope({
|
||||||
|
response: {
|
||||||
|
code: 200,
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
author: {
|
||||||
|
nickname: "抖音作者",
|
||||||
|
sec_uid: secUid,
|
||||||
|
unique_id: "douyin-123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
toolEnvelope({
|
||||||
|
response: {
|
||||||
|
code: 200,
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
nickname: "抖音作者",
|
||||||
|
unique_id: "douyin-123",
|
||||||
|
sec_uid: secUid,
|
||||||
|
follower_count: "1.5万",
|
||||||
|
ip_location: "上海",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const profile = await resolveAccountProfileFromMcp(
|
||||||
|
"https://www.douyin.com/video/7520000000000000000",
|
||||||
|
"抖音",
|
||||||
|
"回填昵称",
|
||||||
|
{ endpoint: "https://collector.example/mcp", key: "test-key" },
|
||||||
|
fetchImpl,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(profile, {
|
||||||
|
platformUid: secUid,
|
||||||
|
nickname: "抖音作者",
|
||||||
|
profileUrl: `https://www.douyin.com/user/${secUid}`,
|
||||||
|
redId: "douyin-123",
|
||||||
|
ipLocation: "上海",
|
||||||
|
followers: 15_000,
|
||||||
|
gender: "",
|
||||||
|
bio: "",
|
||||||
|
recentNoteTitles: [],
|
||||||
|
providerTags: [],
|
||||||
|
});
|
||||||
|
assert.equal(calls[2].body.params.arguments.request.plant, "dy");
|
||||||
|
assert.equal(calls[3].body.params.name, "parse_dy_user_summary");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not treat a Douyin short-link device id as the author sec_uid", async () => {
|
||||||
|
const shortLink = "https://v.douyin.com/5O5VpgomO2U/";
|
||||||
|
const { calls, fetchImpl } = createFakeMcp(
|
||||||
|
[
|
||||||
|
toolEnvelope({
|
||||||
|
response: {
|
||||||
|
code: 200,
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
nickname: "搞怪噜噜😜",
|
||||||
|
user_id: "4065311277723529",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
[shortLink]: "https://www.iesdouyin.com/share/video/7671270545631842038/?did=MS4wLjABAAAA-device-token&with_sec_did=1",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const profile = await resolveAccountProfileFromMcp(
|
||||||
|
shortLink,
|
||||||
|
"抖音",
|
||||||
|
"回填昵称",
|
||||||
|
{ endpoint: "https://collector.example/mcp", key: "test-key" },
|
||||||
|
fetchImpl,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(profile, {
|
||||||
|
platformUid: "4065311277723529",
|
||||||
|
nickname: "搞怪噜噜😜",
|
||||||
|
profileUrl: "",
|
||||||
|
redId: "",
|
||||||
|
ipLocation: "待识别",
|
||||||
|
followers: null,
|
||||||
|
gender: "",
|
||||||
|
bio: "",
|
||||||
|
recentNoteTitles: [],
|
||||||
|
providerTags: [],
|
||||||
|
});
|
||||||
|
assert.equal(calls.find((call) => call.body === null)?.url, shortLink);
|
||||||
|
assert.equal(
|
||||||
|
calls.find((call) => call.body?.params?.name === "parse_dy_user_summary"),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves a Douyin profile only when the public redirect exposes sec_uid", async () => {
|
||||||
|
const shortLink = "https://v.douyin.com/author-sec-uid/";
|
||||||
|
const secUid = "MS4wLjABAAAAOqL4Jdu8htr7EWCDAyIr5z_7uvCAxhj-GOzCWg5zn8bDiKOp3WPw7lWkTyHvZpMY";
|
||||||
|
const { calls, fetchImpl } = createFakeMcp(
|
||||||
|
[
|
||||||
|
toolEnvelope({
|
||||||
|
response: {
|
||||||
|
code: 200,
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
nickname: "抖音作者",
|
||||||
|
user_id: "4065311277723529",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
toolEnvelope({
|
||||||
|
response: {
|
||||||
|
code: 200,
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
nickname: "抖音作者",
|
||||||
|
unique_id: "douyin-987",
|
||||||
|
sec_uid: secUid,
|
||||||
|
follower_count: "2.3万",
|
||||||
|
ip_location: "广东",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
[shortLink]: `https://www.iesdouyin.com/share/video/7671270545631842038/?sec_uid=${secUid}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const profile = await resolveAccountProfileFromMcp(
|
||||||
|
shortLink,
|
||||||
|
"抖音",
|
||||||
|
"回填昵称",
|
||||||
|
{ endpoint: "https://collector.example/mcp", key: "test-key" },
|
||||||
|
fetchImpl,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(profile, {
|
||||||
|
platformUid: secUid,
|
||||||
|
nickname: "抖音作者",
|
||||||
|
profileUrl: `https://www.douyin.com/user/${secUid}`,
|
||||||
|
redId: "douyin-987",
|
||||||
|
ipLocation: "广东",
|
||||||
|
followers: 23_000,
|
||||||
|
gender: "",
|
||||||
|
bio: "",
|
||||||
|
recentNoteTitles: [],
|
||||||
|
providerTags: [],
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
calls.find((call) => call.body?.params?.name === "parse_dy_user_summary")
|
||||||
|
?.body.params.arguments.request.url,
|
||||||
|
`https://www.douyin.com/user/${secUid}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
57
tests/partner-batch-upload.test.mjs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { strToU8, unzipSync, zipSync } from "fflate";
|
||||||
|
import { compactPartnerBatchWorkbookForUpload } from "../koc-portal/app/batch-workbook-upload.ts";
|
||||||
|
|
||||||
|
const imageBytes = (marker, size) => {
|
||||||
|
const bytes = new Uint8Array(size);
|
||||||
|
bytes.set([0x89, 0x50, 0x4e, 0x47, marker]);
|
||||||
|
for (let index = 5; index < bytes.length; index += 1) bytes[index] = marker;
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("slims oversized WPS workbooks without removing backfill screenshots", () => {
|
||||||
|
const worksheet = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||||
|
<sheetData>
|
||||||
|
<row r="1">
|
||||||
|
<c r="A1" t="inlineStr"><is><t>序号(不能改)</t></is></c>
|
||||||
|
<c r="D1" t="inlineStr"><is><t>图片1</t></is></c>
|
||||||
|
<c r="F1" t="inlineStr"><is><t>笔记截图</t></is></c>
|
||||||
|
<c r="G1" t="inlineStr"><is><t>数据分析截图(单篇笔记数据分析截图)</t></is></c>
|
||||||
|
</row>
|
||||||
|
<row r="2">
|
||||||
|
<c r="D2" t="str"><f>_xlfn.DISPIMG("SOURCE",1)</f><v>=DISPIMG("SOURCE",1)</v></c>
|
||||||
|
<c r="F2" t="str"><f>_xlfn.DISPIMG("PUBLISH",1)</f><v>=DISPIMG("PUBLISH",1)</v></c>
|
||||||
|
</row>
|
||||||
|
</sheetData>
|
||||||
|
</worksheet>`;
|
||||||
|
const cellImages = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<etc:cellImages xmlns:etc="http://www.wps.cn/officeDocument/2017/etCustomData" xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||||
|
<etc:cellImage><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="1" name="SOURCE"/></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="rId1"/></xdr:blipFill></xdr:pic></etc:cellImage>
|
||||||
|
<etc:cellImage><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="2" name="PUBLISH"/></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="rId2"/></xdr:blipFill></xdr:pic></etc:cellImage>
|
||||||
|
</etc:cellImages>`;
|
||||||
|
const relationships = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/source.png"/>
|
||||||
|
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/publish.png"/>
|
||||||
|
</Relationships>`;
|
||||||
|
const workbook = zipSync(
|
||||||
|
{
|
||||||
|
"xl/worksheets/sheet1.xml": strToU8(worksheet),
|
||||||
|
"xl/cellimages.xml": strToU8(cellImages),
|
||||||
|
"xl/_rels/cellimages.xml.rels": strToU8(relationships),
|
||||||
|
"xl/media/source.png": [imageBytes(1, 2_000_000), { level: 0 }],
|
||||||
|
"xl/media/publish.png": [imageBytes(2, 2_000), { level: 0 }],
|
||||||
|
},
|
||||||
|
{ level: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const compacted = compactPartnerBatchWorkbookForUpload(workbook);
|
||||||
|
const entries = unzipSync(compacted.bytes);
|
||||||
|
assert.equal(entries["xl/media/source.png"], undefined);
|
||||||
|
assert.deepEqual(entries["xl/media/publish.png"], imageBytes(2, 2_000));
|
||||||
|
assert.equal(compacted.removedMediaCount, 1);
|
||||||
|
assert.equal(compacted.preservedScreenshotCount, 1);
|
||||||
|
assert.ok(compacted.bytes.byteLength < workbook.byteLength / 10);
|
||||||
|
});
|
||||||
327
tests/partner-batch-workbook.test.mjs
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { strFromU8, strToU8, unzipSync, zipSync } from "fflate";
|
||||||
|
import { buildRecoveryWorkbook } from "../lib/recovery-workbook.ts";
|
||||||
|
import {
|
||||||
|
PARTNER_BATCH_HEADERS,
|
||||||
|
buildPartnerBatchWorkbookColumns,
|
||||||
|
parsePartnerBatchWorkbook,
|
||||||
|
resolvePartnerWorkbookOrigin,
|
||||||
|
} from "../lib/partner-batch-workbook.ts";
|
||||||
|
import { hasMp4FileSignature } from "../lib/video-file.ts";
|
||||||
|
|
||||||
|
const png = Uint8Array.from([
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||||
|
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
]);
|
||||||
|
|
||||||
|
test("round-trips hidden assignment IDs and embedded backfill screenshots", () => {
|
||||||
|
const workbook = buildRecoveryWorkbook({
|
||||||
|
sheetName: "批量回填",
|
||||||
|
headers: [...PARTNER_BATCH_HEADERS],
|
||||||
|
columnWidths: [10, 20, 40, 30, 40, 24, 28, 20, 20, 20],
|
||||||
|
hiddenColumns: [7, 8, 9],
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
cells: [
|
||||||
|
1,
|
||||||
|
"测试笔记",
|
||||||
|
"正文 #话题",
|
||||||
|
"",
|
||||||
|
"https://www.xiaohongshu.com/explore/1234567890abcdef",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"distribution-1",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
column: 3,
|
||||||
|
image: { bytes: png, contentType: "image/png", description: "原图" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
column: 5,
|
||||||
|
image: { bytes: png, contentType: "image/png", description: "笔记截图" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
column: 6,
|
||||||
|
image: { bytes: png, contentType: "image/png", description: "数据截图" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const rows = parsePartnerBatchWorkbook(workbook);
|
||||||
|
assert.equal(rows.length, 1);
|
||||||
|
assert.equal(rows[0].distributionId, "distribution-1");
|
||||||
|
assert.equal(rows[0].title, "测试笔记");
|
||||||
|
assert.match(rows[0].publishUrl, /xiaohongshu\.com/);
|
||||||
|
assert.equal(rows[0].publishScreenshot?.contentType, "image/png");
|
||||||
|
assert.equal(rows[0].creatorScreenshot?.contentType, "image/png");
|
||||||
|
|
||||||
|
const entries = unzipSync(workbook);
|
||||||
|
const worksheet = strFromU8(entries["xl/worksheets/sheet1.xml"]);
|
||||||
|
assert.match(worksheet, /序号(不能改)/);
|
||||||
|
assert.doesNotMatch(worksheet, /张原图(见图)|已回填(见图)|请插入笔记截图/);
|
||||||
|
assert.match(worksheet, /<drawing r:id="rId1"\/>/);
|
||||||
|
assert.doesNotMatch(worksheet, /#VALUE!/);
|
||||||
|
const drawing = strFromU8(entries["xl/drawings/drawing1.xml"]);
|
||||||
|
assert.equal((drawing.match(/<xdr:twoCellAnchor editAs="twoCell">/g) ?? []).length, 3);
|
||||||
|
assert.match(drawing, /<xdr:col>3<\/xdr:col>/);
|
||||||
|
assert.match(drawing, /<xdr:col>5<\/xdr:col>/);
|
||||||
|
assert.match(drawing, /<xdr:col>6<\/xdr:col>/);
|
||||||
|
assert.match(worksheet, /min="8" max="8"[^>]*hidden="1"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts the legacy sequence header for previously exported workbooks", () => {
|
||||||
|
const legacyHeaders = [...PARTNER_BATCH_HEADERS];
|
||||||
|
legacyHeaders[0] = "序号";
|
||||||
|
const workbook = buildRecoveryWorkbook({
|
||||||
|
sheetName: "批量回填",
|
||||||
|
headers: legacyHeaders,
|
||||||
|
columnWidths: [10, 20, 40, 30, 40, 24, 28, 20, 20, 20],
|
||||||
|
hiddenColumns: [7, 8, 9],
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
cells: [
|
||||||
|
1,
|
||||||
|
"旧模板笔记",
|
||||||
|
"正文",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"distribution-legacy",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
images: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const rows = parsePartnerBatchWorkbook(workbook);
|
||||||
|
assert.equal(rows[0].sequence, "1");
|
||||||
|
assert.equal(rows[0].distributionId, "distribution-legacy");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("imports dynamic source image columns without confusing screenshot columns", () => {
|
||||||
|
const headers = [
|
||||||
|
"序号(不能改)",
|
||||||
|
"标题",
|
||||||
|
"笔记内容(正文+话题)",
|
||||||
|
"图片1",
|
||||||
|
"图片2",
|
||||||
|
"发布链接",
|
||||||
|
"笔记截图",
|
||||||
|
"数据分析截图(单篇笔记数据分析截图)",
|
||||||
|
"_系统笔记ID",
|
||||||
|
"_原笔记截图",
|
||||||
|
"_原数据分析截图",
|
||||||
|
];
|
||||||
|
const workbook = buildRecoveryWorkbook({
|
||||||
|
sheetName: "批量回填",
|
||||||
|
headers,
|
||||||
|
columnWidths: headers.map(() => 20),
|
||||||
|
hiddenColumns: [8, 9, 10],
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
cells: [
|
||||||
|
1,
|
||||||
|
"多图笔记",
|
||||||
|
"正文",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"https://www.xiaohongshu.com/explore/dynamic",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"distribution-dynamic",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
column: 3,
|
||||||
|
image: { bytes: png, contentType: "image/png", description: "原图1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
column: 4,
|
||||||
|
image: { bytes: png, contentType: "image/png", description: "原图2" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
column: 6,
|
||||||
|
image: { bytes: png, contentType: "image/png", description: "笔记截图" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
column: 7,
|
||||||
|
image: { bytes: png, contentType: "image/png", description: "数据截图" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const rows = parsePartnerBatchWorkbook(workbook);
|
||||||
|
assert.equal(rows[0].distributionId, "distribution-dynamic");
|
||||||
|
assert.equal(rows[0].publishScreenshot?.contentType, "image/png");
|
||||||
|
assert.equal(rows[0].creatorScreenshot?.contentType, "image/png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("imports screenshots saved by WPS as DISPIMG cell images", () => {
|
||||||
|
const sourceImage = Uint8Array.from([...png, 1]);
|
||||||
|
const publishScreenshot = Uint8Array.from([...png, 2]);
|
||||||
|
const creatorScreenshot = Uint8Array.from([...png, 3]);
|
||||||
|
const worksheet = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
|
||||||
|
<sheetData>
|
||||||
|
<row r="1">
|
||||||
|
<c r="A1" t="inlineStr"><is><t>序号(不能改)</t></is></c>
|
||||||
|
<c r="B1" t="inlineStr"><is><t>标题</t></is></c>
|
||||||
|
<c r="C1" t="inlineStr"><is><t>笔记内容(正文+话题)</t></is></c>
|
||||||
|
<c r="D1" t="inlineStr"><is><t>图片1</t></is></c>
|
||||||
|
<c r="E1" t="inlineStr"><is><t>发布链接</t></is></c>
|
||||||
|
<c r="F1" t="inlineStr"><is><t>笔记截图</t></is></c>
|
||||||
|
<c r="G1" t="inlineStr"><is><t>数据分析截图(单篇笔记数据分析截图)</t></is></c>
|
||||||
|
<c r="H1" t="inlineStr"><is><t>_系统笔记ID</t></is></c>
|
||||||
|
<c r="I1" t="inlineStr"><is><t>_原笔记截图</t></is></c>
|
||||||
|
<c r="J1" t="inlineStr"><is><t>_原数据分析截图</t></is></c>
|
||||||
|
</row>
|
||||||
|
<row r="2">
|
||||||
|
<c r="A2"><v>1</v></c>
|
||||||
|
<c r="B2" t="inlineStr"><is><t>WPS 笔记</t></is></c>
|
||||||
|
<c r="D2" t="str"><f>_xlfn.DISPIMG("SOURCE",1)</f><v>=DISPIMG("SOURCE",1)</v></c>
|
||||||
|
<c r="E2" t="inlineStr"><is><t>https://www.xiaohongshu.com/explore/wps</t></is></c>
|
||||||
|
<c r="F2" t="str"><f>_xlfn.DISPIMG("PUBLISH",1)</f><v>=DISPIMG("PUBLISH",1)</v></c>
|
||||||
|
<c r="G2" t="str"><f>_xlfn.DISPIMG("CREATOR",1)</f><v>=DISPIMG("CREATOR",1)</v></c>
|
||||||
|
<c r="H2" t="inlineStr"><is><t>distribution-wps</t></is></c>
|
||||||
|
</row>
|
||||||
|
</sheetData>
|
||||||
|
</worksheet>`;
|
||||||
|
const cellImages = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<etc:cellImages xmlns:etc="http://www.wps.cn/officeDocument/2017/etCustomData" xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||||
|
<etc:cellImage><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="1" name="SOURCE"/></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="rId1"/></xdr:blipFill></xdr:pic></etc:cellImage>
|
||||||
|
<etc:cellImage><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="2" name="PUBLISH"/></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="rId2"/></xdr:blipFill></xdr:pic></etc:cellImage>
|
||||||
|
<etc:cellImage><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="3" name="CREATOR"/></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="rId3"/></xdr:blipFill></xdr:pic></etc:cellImage>
|
||||||
|
</etc:cellImages>`;
|
||||||
|
const relationships = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/source.png"/>
|
||||||
|
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/publish.png"/>
|
||||||
|
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/creator.png"/>
|
||||||
|
</Relationships>`;
|
||||||
|
const workbook = zipSync({
|
||||||
|
"xl/worksheets/sheet1.xml": strToU8(worksheet),
|
||||||
|
"xl/cellimages.xml": strToU8(cellImages),
|
||||||
|
"xl/_rels/cellimages.xml.rels": strToU8(relationships),
|
||||||
|
"xl/media/source.png": sourceImage,
|
||||||
|
"xl/media/publish.png": publishScreenshot,
|
||||||
|
"xl/media/creator.png": creatorScreenshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = parsePartnerBatchWorkbook(workbook);
|
||||||
|
assert.equal(rows.length, 1);
|
||||||
|
assert.equal(rows[0].distributionId, "distribution-wps");
|
||||||
|
assert.deepEqual(rows[0].publishScreenshot?.bytes, publishScreenshot);
|
||||||
|
assert.deepEqual(rows[0].creatorScreenshot?.bytes, creatorScreenshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("builds video-task workbooks with video columns and no source image columns", () => {
|
||||||
|
const columns = buildPartnerBatchWorkbookColumns({
|
||||||
|
contentFormat: "video",
|
||||||
|
maxSourceImages: 3,
|
||||||
|
maxSourceVideos: 2,
|
||||||
|
});
|
||||||
|
assert.deepEqual(columns.headers.slice(0, 8), [
|
||||||
|
"序号(不能改)",
|
||||||
|
"标题",
|
||||||
|
"笔记内容(正文+话题)",
|
||||||
|
"视频1",
|
||||||
|
"视频2",
|
||||||
|
"发布链接",
|
||||||
|
"笔记截图",
|
||||||
|
"数据分析截图(单篇笔记数据分析截图)",
|
||||||
|
]);
|
||||||
|
assert.equal(columns.headers.some((header) => /^图片\d+$/.test(header)), false);
|
||||||
|
|
||||||
|
const workbook = buildRecoveryWorkbook({
|
||||||
|
sheetName: "视频批量回填",
|
||||||
|
headers: columns.headers,
|
||||||
|
columnWidths: columns.columnWidths,
|
||||||
|
hiddenColumns: [
|
||||||
|
columns.systemColumn,
|
||||||
|
columns.systemColumn + 1,
|
||||||
|
columns.systemColumn + 2,
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
cells: [
|
||||||
|
1,
|
||||||
|
"视频笔记",
|
||||||
|
"视频正文 #测试",
|
||||||
|
"下载视频1",
|
||||||
|
"下载视频2",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"distribution-video",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
images: [],
|
||||||
|
hyperlinks: [
|
||||||
|
{
|
||||||
|
column: columns.sourceVideoStartColumn,
|
||||||
|
url: "https://koc.example.com/api/partner-image?kind=video&download=1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const entries = unzipSync(workbook);
|
||||||
|
const worksheet = strFromU8(entries["xl/worksheets/sheet1.xml"]);
|
||||||
|
const relationships = strFromU8(
|
||||||
|
entries["xl/worksheets/_rels/sheet1.xml.rels"],
|
||||||
|
);
|
||||||
|
assert.match(worksheet, /视频1/);
|
||||||
|
assert.doesNotMatch(worksheet, /图片1/);
|
||||||
|
assert.match(relationships, /https:\/\/koc\.example\.com\/api\/partner-image/);
|
||||||
|
assert.match(relationships, /download=1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses the configured public origin before proxy or container addresses", () => {
|
||||||
|
const request = new Request("http://app:3000/api/partner-batch-workbook", {
|
||||||
|
headers: {
|
||||||
|
host: "app:3000",
|
||||||
|
"x-forwarded-host": "internal-proxy:8080",
|
||||||
|
"x-forwarded-proto": "https",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
resolvePartnerWorkbookOrigin(request, "https://koc.example.com/koc/"),
|
||||||
|
"https://koc.example.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves a forwarded non-standard port when no origin is configured", () => {
|
||||||
|
const request = new Request("http://app:3000/api/partner-batch-workbook", {
|
||||||
|
headers: {
|
||||||
|
host: "app:3000",
|
||||||
|
"x-forwarded-host": "localhost:8080",
|
||||||
|
"x-forwarded-proto": "http",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(resolvePartnerWorkbookOrigin(request), "http://localhost:8080");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recognizes MP4 bytes instead of trusting a response content type", () => {
|
||||||
|
const mp4Header = Uint8Array.from([
|
||||||
|
0x00, 0x00, 0x00, 0x18,
|
||||||
|
0x66, 0x74, 0x79, 0x70,
|
||||||
|
0x69, 0x73, 0x6f, 0x6d,
|
||||||
|
0x00, 0x00, 0x02, 0x00,
|
||||||
|
0x69, 0x73, 0x6f, 0x6d,
|
||||||
|
0x6d, 0x70, 0x34, 0x32,
|
||||||
|
]);
|
||||||
|
assert.equal(hasMp4FileSignature(mp4Header), true);
|
||||||
|
assert.equal(hasMp4FileSignature(new TextEncoder().encode("not a video")), false);
|
||||||
|
});
|
||||||
@@ -16,7 +16,7 @@ test("creates an xlsx archive whose pictures are embedded in worksheet cells", (
|
|||||||
columnWidths: [8, 24, 24, 24],
|
columnWidths: [8, 24, 24, 24],
|
||||||
rows: [
|
rows: [
|
||||||
{
|
{
|
||||||
cells: [1, "测试笔记", "见图", "见图"],
|
cells: [1, "测试笔记", "", ""],
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
column: 2,
|
column: 2,
|
||||||
@@ -43,12 +43,24 @@ test("creates an xlsx archive whose pictures are embedded in worksheet cells", (
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const archive = unzipSync(workbook);
|
const archive = unzipSync(workbook);
|
||||||
|
const worksheet = strFromU8(archive["xl/worksheets/sheet1.xml"]);
|
||||||
|
|
||||||
assert.ok(archive["xl/media/image1.png"]);
|
assert.ok(archive["xl/media/image1.png"]);
|
||||||
assert.ok(archive["xl/media/image2.png"]);
|
assert.ok(archive["xl/media/image2.png"]);
|
||||||
assert.match(strFromU8(archive["xl/worksheets/sheet1.xml"]), /<drawing r:id="rId1"\/>/);
|
assert.match(worksheet, /<drawing r:id="rId1"\/>/);
|
||||||
assert.match(strFromU8(archive["xl/drawings/drawing1.xml"]), /oneCellAnchor/);
|
assert.doesNotMatch(worksheet, /#VALUE!/);
|
||||||
assert.match(strFromU8(archive["xl/drawings/_rels/drawing1.xml.rels"]), /image2\.png/);
|
const drawing = strFromU8(archive["xl/drawings/drawing1.xml"]);
|
||||||
|
assert.equal((drawing.match(/<xdr:twoCellAnchor editAs="twoCell">/g) ?? []).length, 2);
|
||||||
|
assert.match(drawing, /<xdr:col>2<\/xdr:col>/);
|
||||||
|
assert.match(drawing, /<xdr:col>3<\/xdr:col>/);
|
||||||
|
assert.match(
|
||||||
|
strFromU8(archive["xl/drawings/_rels/drawing1.xml.rels"]),
|
||||||
|
/image2\.png/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
worksheet,
|
||||||
|
/见图/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("creates clickable external hyperlinks for resource exports", () => {
|
test("creates clickable external hyperlinks for resource exports", () => {
|
||||||
|
|||||||
@@ -23,6 +23,30 @@ test("builds the KOC LOOP product shell", async () => {
|
|||||||
await access(new URL("../.next/static", import.meta.url));
|
await access(new URL("../.next/static", import.meta.url));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps distribution filters as always-visible fuzzy search fields", async () => {
|
||||||
|
const [adminApp, globalCss] = await Promise.all([
|
||||||
|
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(globalCss, /\.distribution-task-search\s*\{[^}]*flex-direction:\s*row/s);
|
||||||
|
assert.match(globalCss, /\.distribution-task-search\s*\{[^}]*margin:\s*0/s);
|
||||||
|
assert.match(globalCss, /\.distribution-task-search\s*>\s*span\s*\{[^}]*flex:\s*0\s+0\s+18px/s);
|
||||||
|
assert.match(globalCss, /\.distribution-task-filter-combobox\.brand\s*\{[^}]*flex-basis:\s*176px/s);
|
||||||
|
assert.match(globalCss, /\.distribution-task-filter-menu\s*\{[^}]*position:\s*absolute[^}]*z-index:\s*60/s);
|
||||||
|
assert.match(globalCss, /\.distribution-task-filter-input\s*\{[^}]*display:\s*flex[^}]*margin:\s*0/s);
|
||||||
|
assert.match(adminApp, /aria-expanded=\{openTaskFilter === filter\.key\}/);
|
||||||
|
assert.match(adminApp, /role="combobox"/);
|
||||||
|
assert.match(adminApp, /role="listbox"/);
|
||||||
|
assert.match(adminApp, /placeholder=\{`搜索\$\{filter\.label\}`\}/);
|
||||||
|
assert.match(adminApp, /setTaskFilterValue\(filter\.key, event\.target\.value\)/);
|
||||||
|
assert.doesNotMatch(adminApp, /distribution-task-filter-trigger/);
|
||||||
|
assert.doesNotMatch(adminApp, /distribution-task-filter-menu-search/);
|
||||||
|
assert.doesNotMatch(adminApp, /taskFilterQuery/);
|
||||||
|
assert.doesNotMatch(adminApp, /distribution-task-option-panel/);
|
||||||
|
assert.doesNotMatch(adminApp, /<select value=\{contentTypeFilter\}/);
|
||||||
|
});
|
||||||
|
|
||||||
test("stacks user management and securely removes departed accounts", async () => {
|
test("stacks user management and securely removes departed accounts", async () => {
|
||||||
const [usersPage, usersRoute, globalCss] = await Promise.all([
|
const [usersPage, usersRoute, globalCss] = await Promise.all([
|
||||||
readFile(new URL("../app/users-page.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../app/users-page.tsx", import.meta.url), "utf8"),
|
||||||
@@ -137,8 +161,8 @@ test("issues external task links and supports one-to-one note submissions", asyn
|
|||||||
assert.match(partnerRoute, /claimantIdentifier\.canonical/);
|
assert.match(partnerRoute, /claimantIdentifier\.canonical/);
|
||||||
assert.match(partnerRoute, /legacyPartnerId/);
|
assert.match(partnerRoute, /legacyPartnerId/);
|
||||||
assert.match(partnerRoute, /微信号或手机号/);
|
assert.match(partnerRoute, /微信号或手机号/);
|
||||||
assert.match(partnerRoute, /extractXhsPublishUrl/);
|
assert.match(partnerRoute, /extractPublishUrl/);
|
||||||
assert.match(partnerRoute, /小红书长链或短链/);
|
assert.match(partnerRoute, /task\.platform/);
|
||||||
assert.match(partnerRoute, /请填写发布链接/);
|
assert.match(partnerRoute, /请填写发布链接/);
|
||||||
assert.doesNotMatch(partnerRoute, /请填写发布账号昵称和发布链接/);
|
assert.doesNotMatch(partnerRoute, /请填写发布账号昵称和发布链接/);
|
||||||
assert.match(partnerRoute, /没有找到领取记录/);
|
assert.match(partnerRoute, /没有找到领取记录/);
|
||||||
@@ -147,11 +171,16 @@ test("issues external task links and supports one-to-one note submissions", asyn
|
|||||||
assert.match(partnerRoute, /enrichDistributionAccount/);
|
assert.match(partnerRoute, /enrichDistributionAccount/);
|
||||||
assert.match(partnerRoute, /runInBackground\(enrichment/);
|
assert.match(partnerRoute, /runInBackground\(enrichment/);
|
||||||
assert.match(accountEnrichment, /profile_url = excluded\.profile_url/);
|
assert.match(accountEnrichment, /profile_url = excluded\.profile_url/);
|
||||||
assert.match(accountEnrichment, /resolveXhsAccountProfileFromMcp/);
|
assert.match(accountEnrichment, /resolveAccountProfileFromMcp/);
|
||||||
assert.match(accountEnrichment, /resolveXhsProfileDetailsFromMcp/);
|
assert.match(accountEnrichment, /resolveProfileDetailsFromMcp/);
|
||||||
assert.match(accountEnrichment, /isVerifiedXhsProfileUrl/);
|
assert.match(accountEnrichment, /isVerifiedXhsProfileUrl/);
|
||||||
assert.match(accountEnrichment, /endsWith\("\.xiaohongshu\.com"\)/);
|
assert.match(accountEnrichment, /endsWith\("\.xiaohongshu\.com"\)/);
|
||||||
assert.match(accountEnrichment, /DELETE FROM accounts/);
|
assert.match(accountEnrichment, /DELETE FROM accounts/);
|
||||||
|
assert.match(accountEnrichment, /WHERE platform = \? AND platform_uid = \?/);
|
||||||
|
assert.match(accountEnrichment, /existingAccount\?\.id \|\| canonicalAccountId/);
|
||||||
|
assert.match(accountEnrichment, /cl\.claimant_name AS claimant_contact/);
|
||||||
|
assert.match(accountEnrichment, /current_contact = CASE/);
|
||||||
|
assert.match(accountEnrichment, /!row\.resolved_account_id/);
|
||||||
assert.match(accountEnrichment, /a\.followers/);
|
assert.match(accountEnrichment, /a\.followers/);
|
||||||
assert.match(accountEnrichment, /followers = CASE/);
|
assert.match(accountEnrichment, /followers = CASE/);
|
||||||
assert.match(accountEnrichment, /Number\(row\.followers \?\? 0\) === 0/);
|
assert.match(accountEnrichment, /Number\(row\.followers \?\? 0\) === 0/);
|
||||||
@@ -171,6 +200,11 @@ test("issues external task links and supports one-to-one note submissions", asyn
|
|||||||
assert.match(imageRoute, /creator-center\//);
|
assert.match(imageRoute, /creator-center\//);
|
||||||
assert.match(imageRoute, /imageKind === "publish"/);
|
assert.match(imageRoute, /imageKind === "publish"/);
|
||||||
assert.match(imageRoute, /imageKind === "creator"/);
|
assert.match(imageRoute, /imageKind === "creator"/);
|
||||||
|
assert.match(imageRoute, /isMutableEvidence/);
|
||||||
|
assert.match(imageRoute, /"private, no-store"/);
|
||||||
|
assert.match(imageRoute, /Content-Type", "video\/mp4"/);
|
||||||
|
assert.match(imageRoute, /video-\$\{imageIndex\}\.mp4/);
|
||||||
|
assert.match(imageRoute, /downloadRequested \? "attachment" : "inline"/);
|
||||||
assert.match(imageRoute, /cl\.claim_token/);
|
assert.match(imageRoute, /cl\.claim_token/);
|
||||||
assert.match(imageUploadRoute, /isAdminRequest/);
|
assert.match(imageUploadRoute, /isAdminRequest/);
|
||||||
assert.match(cors, /KOC_PORTAL_URL/);
|
assert.match(cors, /KOC_PORTAL_URL/);
|
||||||
@@ -178,6 +212,17 @@ test("issues external task links and supports one-to-one note submissions", asyn
|
|||||||
assert.match(cors, /X-KOC-Upload-Kind/);
|
assert.match(cors, /X-KOC-Upload-Kind/);
|
||||||
assert.match(cors, /Access-Control-Allow-Origin/);
|
assert.match(cors, /Access-Control-Allow-Origin/);
|
||||||
assert.match(adminApp, /hasCreatorMetrics/);
|
assert.match(adminApp, /hasCreatorMetrics/);
|
||||||
|
assert.match(adminApp, /function PlatformBadge/);
|
||||||
|
assert.match(adminApp, /function resourceProfileLink/);
|
||||||
|
assert.match(adminApp, /搜索主页/);
|
||||||
|
assert.match(adminApp, /latest_publish_url/);
|
||||||
|
assert.match(adminApp, /通过作品查看主页/);
|
||||||
|
assert.match(adminApp, /platform-logo/);
|
||||||
|
assert.match(adminApp, /按任务名称模糊搜索/);
|
||||||
|
assert.match(adminApp, /全部品牌\/项目/);
|
||||||
|
assert.match(adminApp, /全部内容类型/);
|
||||||
|
assert.match(adminApp, /全部平台/);
|
||||||
|
assert.match(adminApp, /task-scope-subline/);
|
||||||
assert.match(adminApp, /待KOC填写数据/);
|
assert.match(adminApp, /待KOC填写数据/);
|
||||||
assert.match(adminApp, /AdminImageLightbox/);
|
assert.match(adminApp, /AdminImageLightbox/);
|
||||||
assert.match(adminApp, /CreatorScreenshotPreview/);
|
assert.match(adminApp, /CreatorScreenshotPreview/);
|
||||||
@@ -195,6 +240,43 @@ test("issues external task links and supports one-to-one note submissions", asyn
|
|||||||
assert.match(migration, /claim_token/);
|
assert.match(migration, /claim_token/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("exports and imports claim-bound Excel backfill workbooks", async () => {
|
||||||
|
const [route, parser, workbook, imageNormalizer, nginx] = await Promise.all([
|
||||||
|
readFile(new URL("../app/api/partner-batch-workbook/route.ts", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../lib/partner-batch-workbook.ts", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../lib/recovery-workbook.ts", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../lib/workbook-image.ts", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../deploy/nginx/koc-loop.conf", import.meta.url), "utf8"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(route, /批量回填/);
|
||||||
|
assert.match(route, /publish-evidence/);
|
||||||
|
assert.match(route, /creator-center/);
|
||||||
|
assert.match(route, /extractPublishUrl/);
|
||||||
|
assert.match(route, /findAccess/);
|
||||||
|
assert.match(route, /isDifferentFromStoredImage/);
|
||||||
|
assert.match(route, /rowIndex \+ 1/);
|
||||||
|
assert.match(route, /buildPartnerBatchWorkbookColumns/);
|
||||||
|
assert.match(route, /resolvePartnerWorkbookOrigin/);
|
||||||
|
assert.match(route, /download: "1"/);
|
||||||
|
assert.match(route, /columns\.sourceImageStartColumn \+ index/);
|
||||||
|
assert.match(parser, /_系统笔记ID/);
|
||||||
|
assert.match(parser, /笔记截图/);
|
||||||
|
assert.match(parser, /数据分析截图(单篇笔记数据分析截图)/);
|
||||||
|
assert.match(parser, /parseImages/);
|
||||||
|
assert.match(workbook, /hiddenColumns/);
|
||||||
|
assert.match(workbook, /offsetX/);
|
||||||
|
assert.doesNotMatch(workbook, /value \|\| "见图"/);
|
||||||
|
assert.match(imageNormalizer, /\.rotate\(\)/);
|
||||||
|
assert.match(imageNormalizer, /\.png\(/);
|
||||||
|
assert.match(nginx, /client_max_body_size 85m/);
|
||||||
|
assert.equal((nginx.match(/proxy_set_header Host \$http_host;/g) ?? []).length, 2);
|
||||||
|
assert.equal(
|
||||||
|
(nginx.match(/proxy_set_header X-Forwarded-Host \$http_host;/g) ?? []).length,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("supports task collection schedules and latest public metrics", async () => {
|
test("supports task collection schedules and latest public metrics", async () => {
|
||||||
const [
|
const [
|
||||||
adminApp,
|
adminApp,
|
||||||
@@ -206,6 +288,7 @@ test("supports task collection schedules and latest public metrics", async () =>
|
|||||||
migration,
|
migration,
|
||||||
accountMigration,
|
accountMigration,
|
||||||
compose,
|
compose,
|
||||||
|
mcpClient,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../app/api/action/route.ts", import.meta.url), "utf8"),
|
readFile(new URL("../app/api/action/route.ts", import.meta.url), "utf8"),
|
||||||
@@ -216,6 +299,7 @@ test("supports task collection schedules and latest public metrics", async () =>
|
|||||||
readFile(new URL("../mysql/0001_init.sql", import.meta.url), "utf8"),
|
readFile(new URL("../mysql/0001_init.sql", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../mysql/0001_init.sql", import.meta.url), "utf8"),
|
readFile(new URL("../mysql/0001_init.sql", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../docker-compose.self-hosted.yml", import.meta.url), "utf8"),
|
readFile(new URL("../docker-compose.self-hosted.yml", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../lib/mcp-collection-client.ts", import.meta.url), "utf8"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
for (const label of [
|
for (const label of [
|
||||||
@@ -238,12 +322,19 @@ test("supports task collection schedules and latest public metrics", async () =>
|
|||||||
assert.match(adminApp, /sortWithNullsLast/);
|
assert.match(adminApp, /sortWithNullsLast/);
|
||||||
assert.match(adminApp, /内容 \/ 发布账号/);
|
assert.match(adminApp, /内容 \/ 发布账号/);
|
||||||
assert.match(adminApp, /recovery-title-link/);
|
assert.match(adminApp, /recovery-title-link/);
|
||||||
assert.match(adminApp, /打开小红书笔记/);
|
assert.match(adminApp, /打开\$\{selectedTask\.platform\}作品/);
|
||||||
assert.match(adminApp, /target="_blank"/);
|
assert.match(adminApp, /target="_blank"/);
|
||||||
assert.match(adminApp, /noopener noreferrer/);
|
assert.match(adminApp, /noopener noreferrer/);
|
||||||
assert.match(adminApp, /const noteUrl = xhsPublishUrl\(item\.publish_url\)/);
|
assert.match(adminApp, /const noteUrl = publicPublishUrl\(item\.publish_url\)/);
|
||||||
assert.match(adminApp, /noteUrl \? \(/);
|
assert.match(adminApp, /noteUrl \? \(/);
|
||||||
|
assert.match(adminApp, /updateDistributionPublishUrl/);
|
||||||
|
assert.match(adminApp, /填写链接/);
|
||||||
|
assert.match(adminApp, /更新链接/);
|
||||||
assert.match(adminApp, /hostname === "xhslink\.cn"/);
|
assert.match(adminApp, /hostname === "xhslink\.cn"/);
|
||||||
|
assert.match(actionRoute, /update_distribution_publish_url/);
|
||||||
|
assert.match(actionRoute, /extractPublishUrl/);
|
||||||
|
assert.match(actionRoute, /DELETE FROM collection_runs WHERE distribution_id = \?/);
|
||||||
|
assert.match(actionRoute, /enrichDistributionAccount/);
|
||||||
assert.match(actionRoute, /save_collection_schedule/);
|
assert.match(actionRoute, /save_collection_schedule/);
|
||||||
assert.match(actionRoute, /collect_now/);
|
assert.match(actionRoute, /collect_now/);
|
||||||
assert.match(actionRoute, /backfill_account_profiles/);
|
assert.match(actionRoute, /backfill_account_profiles/);
|
||||||
@@ -253,7 +344,11 @@ test("supports task collection schedules and latest public metrics", async () =>
|
|||||||
assert.match(actionRoute, /createCollectionRunTasks/);
|
assert.match(actionRoute, /createCollectionRunTasks/);
|
||||||
assert.match(bootstrapRoute, /runDueScheduledCollections/);
|
assert.match(bootstrapRoute, /runDueScheduledCollections/);
|
||||||
assert.match(collectionService, /INSERT OR IGNORE INTO collection_runs/);
|
assert.match(collectionService, /INSERT OR IGNORE INTO collection_runs/);
|
||||||
assert.match(collectionService, /collectXhsMetricsFromMcp/);
|
assert.match(
|
||||||
|
collectionService,
|
||||||
|
/run\.status === "success" && source !== "manual"/,
|
||||||
|
);
|
||||||
|
assert.match(collectionService, /collectMetricsFromMcp/);
|
||||||
assert.doesNotMatch(collectionService, /hashText/);
|
assert.doesNotMatch(collectionService, /hashText/);
|
||||||
assert.match(collectionService, /runScheduledCollections/);
|
assert.match(collectionService, /runScheduledCollections/);
|
||||||
assert.match(collectionService, /runDueScheduledCollections/);
|
assert.match(collectionService, /runDueScheduledCollections/);
|
||||||
@@ -272,6 +367,10 @@ test("supports task collection schedules and latest public metrics", async () =>
|
|||||||
assert.match(migration, /collection_runs_distribution_date_idx/);
|
assert.match(migration, /collection_runs_distribution_date_idx/);
|
||||||
assert.match(accountMigration, /public_account_id/);
|
assert.match(accountMigration, /public_account_id/);
|
||||||
assert.match(compose, /ENABLE_SCHEDULER/);
|
assert.match(compose, /ENABLE_SCHEDULER/);
|
||||||
|
assert.match(mcpClient, /"fetch_content_detail"/);
|
||||||
|
assert.match(mcpClient, /"parse_xhs_user_summary"/);
|
||||||
|
assert.doesNotMatch(mcpClient, /"parse_xhs_note"/);
|
||||||
|
assert.doesNotMatch(mcpClient, /"collect_xhs_wen_note_detail"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("supports fixed screenshot collection tasks without publish metrics", async () => {
|
test("supports fixed screenshot collection tasks without publish metrics", async () => {
|
||||||
@@ -314,7 +413,7 @@ test("exports complete task recovery data to Excel with embedded images", async
|
|||||||
assert.match(adminApp, /导出全部数据/);
|
assert.match(adminApp, /导出全部数据/);
|
||||||
assert.match(adminApp, /\/api\/recovery-export/);
|
assert.match(adminApp, /\/api\/recovery-export/);
|
||||||
assert.match(adminApp, /图片和截图已嵌入表格/);
|
assert.match(adminApp, /图片和截图已嵌入表格/);
|
||||||
assert.match(exportRoute, /小红书昵称/);
|
assert.match(exportRoute, /`\$\{task\.platform\}昵称`/);
|
||||||
assert.match(exportRoute, /曝光量-实际(第7天)/);
|
assert.match(exportRoute, /曝光量-实际(第7天)/);
|
||||||
assert.match(exportRoute, /阅读量-实际(第7天)/);
|
assert.match(exportRoute, /阅读量-实际(第7天)/);
|
||||||
assert.match(exportRoute, /publish_screenshot_key/);
|
assert.match(exportRoute, /publish_screenshot_key/);
|
||||||
@@ -323,8 +422,9 @@ test("exports complete task recovery data to Excel with embedded images", async
|
|||||||
assert.match(exportRoute, /isAdminRequest/);
|
assert.match(exportRoute, /isAdminRequest/);
|
||||||
assert.match(exportRoute, /consumeMcpExportToken/);
|
assert.match(exportRoute, /consumeMcpExportToken/);
|
||||||
assert.match(workbook, /xl\/drawings\/drawing1\.xml/);
|
assert.match(workbook, /xl\/drawings\/drawing1\.xml/);
|
||||||
|
assert.match(workbook, /twoCellAnchor editAs="twoCell"/);
|
||||||
assert.match(workbook, /xl\/media\/image/);
|
assert.match(workbook, /xl\/media\/image/);
|
||||||
assert.match(workbook, /oneCellAnchor/);
|
assert.match(workbook, /relationships\/image/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("provides simple username-password login and three server-enforced roles", async () => {
|
test("provides simple username-password login and three server-enforced roles", async () => {
|
||||||
@@ -393,6 +493,7 @@ test("filters and exports the current KOC resource result set", async () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
assert.match(adminApp, /搜索账号名称 \/ 账号ID/);
|
assert.match(adminApp, /搜索账号名称 \/ 账号ID/);
|
||||||
|
assert.match(adminApp, /当前联系人/);
|
||||||
assert.match(adminApp, /搜索IP地区/);
|
assert.match(adminApp, /搜索IP地区/);
|
||||||
assert.match(adminApp, /搜索合作来源/);
|
assert.match(adminApp, /搜索合作来源/);
|
||||||
assert.match(adminApp, /ipLocation\.includes\(ipKeyword\)/);
|
assert.match(adminApp, /ipLocation\.includes\(ipKeyword\)/);
|
||||||
@@ -401,6 +502,7 @@ test("filters and exports the current KOC resource result set", async () => {
|
|||||||
assert.match(adminApp, /filteredAccounts\.map\(\(item\) => item\.account\.id\)/);
|
assert.match(adminApp, /filteredAccounts\.map\(\(item\) => item\.account\.id\)/);
|
||||||
assert.match(exportRoute, /小红书号\/抖音号/);
|
assert.match(exportRoute, /小红书号\/抖音号/);
|
||||||
assert.match(exportRoute, /历史合作来源/);
|
assert.match(exportRoute, /历史合作来源/);
|
||||||
|
assert.match(exportRoute, /当前联系人/);
|
||||||
assert.match(exportRoute, /合作社资源 · 不可直联/);
|
assert.match(exportRoute, /合作社资源 · 不可直联/);
|
||||||
assert.match(exportRoute, /isManagerRequest/);
|
assert.match(exportRoute, /isManagerRequest/);
|
||||||
assert.match(exportRoute, /consumeMcpExportToken/);
|
assert.match(exportRoute, /consumeMcpExportToken/);
|
||||||
@@ -408,21 +510,60 @@ test("filters and exports the current KOC resource result set", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("imports existing KOC resources through a validated spreadsheet preview", async () => {
|
test("imports existing KOC resources through a validated spreadsheet preview", async () => {
|
||||||
const [adminApp, importRoute, resourceParser, accountMigration] = await Promise.all([
|
const [adminApp, globalCss, importRoute, resourceParser, accountMigration, profileMigration, contactMigration] = await Promise.all([
|
||||||
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
|
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../app/api/resources-import/route.ts", import.meta.url), "utf8"),
|
readFile(new URL("../app/api/resources-import/route.ts", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../lib/resource-import.ts", import.meta.url), "utf8"),
|
readFile(new URL("../lib/resource-import.ts", import.meta.url), "utf8"),
|
||||||
readFile(new URL("../mysql/0002_resource_import.sql", import.meta.url), "utf8"),
|
readFile(new URL("../mysql/0002_resource_import.sql", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../mysql/0006_account_profile_tags.sql", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../mysql/0007_account_current_contact.sql", import.meta.url), "utf8"),
|
||||||
]);
|
]);
|
||||||
assert.match(adminApp, /下载导入模板/);
|
assert.match(adminApp, /下载导入模板/);
|
||||||
assert.match(adminApp, /校验并预览/);
|
assert.match(adminApp, /校验并预览/);
|
||||||
assert.match(adminApp, /确认导入/);
|
assert.match(adminApp, /确认导入/);
|
||||||
assert.match(importRoute, /isManagerRequest/);
|
assert.match(importRoute, /isManagerRequest/);
|
||||||
assert.match(importRoute, /mode !== "commit"/);
|
assert.match(importRoute, /mode !== "commit"/);
|
||||||
assert.match(resourceParser, /RESOURCE_IMPORT_MAX_ROWS = 100/);
|
assert.match(resourceParser, /RESOURCE_IMPORT_MAX_ROWS = 10_000/);
|
||||||
assert.match(resourceParser, /当前自动解析仅支持小红书账号主页/);
|
assert.match(resourceParser, /RESOURCE_IMPORT_MAX_BYTES = 20 \* 1024 \* 1024/);
|
||||||
assert.match(importRoute, /resolveXhsProfileDetailsFromMcp/);
|
assert.match(adminApp, /单次最多 10,000 个账号,文件不超过 20MB/);
|
||||||
|
assert.match(importRoute, /RESOURCE_IMPORT_DB_BATCH_SIZE = 100/);
|
||||||
|
assert.match(importRoute, /bulk resource profile enrichment/);
|
||||||
|
assert.match(adminApp, /异常数据将自动跳过,不会导入/);
|
||||||
|
assert.match(
|
||||||
|
adminApp,
|
||||||
|
/summary\.create \+ importPreview\.summary\.update === 0 \|\| importWorking/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
adminApp,
|
||||||
|
/disabled=\{importPreview\.summary\.error > 0 \|\| importWorking\}/,
|
||||||
|
);
|
||||||
|
assert.match(importRoute, /const importableRows = analyzed\.filter/);
|
||||||
|
assert.match(importRoute, /跳过 \$\{summary\.error\} 条异常数据/);
|
||||||
|
assert.match(importRoute, /previewAnalyzedRows\(analyzed\)/);
|
||||||
|
assert.match(resourceParser, /当前自动解析仅支持小红书或抖音账号主页/);
|
||||||
|
assert.match(importRoute, /resolveProfileDetailsFromMcp/);
|
||||||
assert.match(accountMigration, /cooperation_source/);
|
assert.match(accountMigration, /cooperation_source/);
|
||||||
|
assert.match(profileMigration, /ADD COLUMN gender/);
|
||||||
|
assert.match(profileMigration, /ADD COLUMN bio/);
|
||||||
|
assert.match(profileMigration, /ADD COLUMN tags/);
|
||||||
|
assert.match(contactMigration, /ADD COLUMN current_contact/);
|
||||||
|
assert.match(adminApp, /gender-icon male/);
|
||||||
|
assert.match(adminApp, /gender-icon female/);
|
||||||
|
assert.match(adminApp, /resource-profile-avatar/);
|
||||||
|
assert.match(adminApp, /resource-account-number/);
|
||||||
|
assert.match(adminApp, /resource-platform-line/);
|
||||||
|
assert.match(adminApp, /resource-latest/);
|
||||||
|
assert.match(adminApp, /合作来源/);
|
||||||
|
assert.match(adminApp, /待打标/);
|
||||||
|
assert.doesNotMatch(adminApp, /className="verified-dot"/);
|
||||||
|
assert.match(globalCss, /\.resource-tags\s*\{[^}]*margin-bottom:\s*auto/s);
|
||||||
|
assert.match(globalCss, /\.resource-card-foot\s*\{[^}]*margin-top:\s*10px/s);
|
||||||
|
assert.match(adminApp, /resource-tags/);
|
||||||
|
assert.match(adminApp, /最多 5 个/);
|
||||||
|
assert.match(resourceParser, /gender: \["性别"\]/);
|
||||||
|
assert.match(resourceParser, /bio: \["简介"/);
|
||||||
|
assert.match(resourceParser, /tags: \["标签"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("supports anonymous partner delegation without creating a second data flow", async () => {
|
test("supports anonymous partner delegation without creating a second data flow", async () => {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
import { strFromU8, strToU8, unzipSync, zipSync } from "fflate";
|
||||||
import { buildRecoveryWorkbook } from "../lib/recovery-workbook.ts";
|
import { buildRecoveryWorkbook } from "../lib/recovery-workbook.ts";
|
||||||
import {
|
import {
|
||||||
|
RESOURCE_IMPORT_MAX_ROWS,
|
||||||
mergeCooperationSources,
|
mergeCooperationSources,
|
||||||
mergeTags,
|
|
||||||
normalizeProfileUrl,
|
normalizeProfileUrl,
|
||||||
parseResourceFollowers,
|
parseResourceFollowers,
|
||||||
parseResourceImportFile,
|
parseResourceImportFile,
|
||||||
@@ -11,6 +12,72 @@ import {
|
|||||||
resourcePlatformUid,
|
resourcePlatformUid,
|
||||||
} from "../lib/resource-import.ts";
|
} from "../lib/resource-import.ts";
|
||||||
|
|
||||||
|
test("accepts several thousand accounts in one import file", () => {
|
||||||
|
const csv = [
|
||||||
|
"账号主页,账号昵称,账号ID,IP属地,粉丝数,合作来源",
|
||||||
|
...Array.from({ length: 3_000 }, (_, index) => {
|
||||||
|
const id = String(index + 1).padStart(6, "0");
|
||||||
|
return `https://www.xiaohongshu.com/user/profile/bulk${id},账号${id},${id},上海,100,批量资源`;
|
||||||
|
}),
|
||||||
|
].join("\n");
|
||||||
|
const rows = parseResourceImportFile(
|
||||||
|
"bulk-resources.csv",
|
||||||
|
new TextEncoder().encode(csv),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(RESOURCE_IMPORT_MAX_ROWS, 10_000);
|
||||||
|
assert.equal(rows.length, 3_000);
|
||||||
|
assert.equal(rows[0].rowNumber, 2);
|
||||||
|
assert.equal(rows.at(-1)?.rowNumber, 3_001);
|
||||||
|
assert.equal(rows.every((row) => row.errors.length === 0), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses several thousand accounts from an XLSX workbook", () => {
|
||||||
|
const workbook = buildRecoveryWorkbook({
|
||||||
|
sheetName: "KOC资源导入",
|
||||||
|
headers: ["账号主页", "账号昵称", "账号ID", "IP属地", "粉丝数", "合作来源"],
|
||||||
|
columnWidths: [48, 24, 20, 16, 14, 24],
|
||||||
|
rows: Array.from({ length: 3_000 }, (_, index) => {
|
||||||
|
const id = String(index + 1).padStart(6, "0");
|
||||||
|
return {
|
||||||
|
cells: [
|
||||||
|
`https://www.xiaohongshu.com/user/profile/xlsx${id}`,
|
||||||
|
`账号${id}`,
|
||||||
|
id,
|
||||||
|
"北京",
|
||||||
|
200,
|
||||||
|
"Excel批量资源",
|
||||||
|
],
|
||||||
|
images: [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const rows = parseResourceImportFile("bulk-resources.xlsx", workbook);
|
||||||
|
|
||||||
|
assert.equal(rows.length, 3_000);
|
||||||
|
assert.equal(rows[0].profileUrl.endsWith("xlsx000001"), true);
|
||||||
|
assert.equal(rows.at(-1)?.profileUrl.endsWith("xlsx003000"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps a bounded 10,000-row safety limit", () => {
|
||||||
|
const csv = [
|
||||||
|
"账号主页",
|
||||||
|
...Array.from(
|
||||||
|
{ length: RESOURCE_IMPORT_MAX_ROWS + 1 },
|
||||||
|
(_, index) => `https://www.douyin.com/user/bulk-account-${index + 1}`,
|
||||||
|
),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
parseResourceImportFile(
|
||||||
|
"too-many-resources.csv",
|
||||||
|
new TextEncoder().encode(csv),
|
||||||
|
),
|
||||||
|
/单次最多导入 10000 个账号/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("parses CSV resources and normalizes public profile data", () => {
|
test("parses CSV resources and normalizes public profile data", () => {
|
||||||
const csv = [
|
const csv = [
|
||||||
"账号主页,合作来源",
|
"账号主页,合作来源",
|
||||||
@@ -27,8 +94,10 @@ test("parses CSV resources and normalizes public profile data", () => {
|
|||||||
ipLocation: "",
|
ipLocation: "",
|
||||||
followers: 0,
|
followers: 0,
|
||||||
followersResolved: false,
|
followersResolved: false,
|
||||||
|
gender: "",
|
||||||
|
bio: "",
|
||||||
|
tags: [],
|
||||||
cooperationSource: "林林KOC社群",
|
cooperationSource: "林林KOC社群",
|
||||||
tags: "",
|
|
||||||
errors: [],
|
errors: [],
|
||||||
});
|
});
|
||||||
assert.equal(resourcePlatformUid(rows[0]), "abc123");
|
assert.equal(resourcePlatformUid(rows[0]), "abc123");
|
||||||
@@ -36,8 +105,8 @@ test("parses CSV resources and normalizes public profile data", () => {
|
|||||||
|
|
||||||
test("uses optional account fields directly and only requires the profile URL", () => {
|
test("uses optional account fields directly and only requires the profile URL", () => {
|
||||||
const csv = [
|
const csv = [
|
||||||
"账号链接,账号昵称,账号ID,IP属地,粉丝数,合作来源",
|
"账号链接,账号昵称,账号ID,IP属地,粉丝数,性别,简介,标签,合作来源",
|
||||||
"https://www.xiaohongshu.com/user/profile/abc123,番茄不炒蛋,4171542126,江西,10+,历史资源",
|
"https://www.xiaohongshu.com/user/profile/abc123,番茄不炒蛋,4171542126,江西,10+,女,分享江西本地生活,本地生活、美食探店,历史资源",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
const [row] = parseResourceImportFile(
|
const [row] = parseResourceImportFile(
|
||||||
"resources.csv",
|
"resources.csv",
|
||||||
@@ -48,9 +117,36 @@ test("uses optional account fields directly and only requires the profile URL",
|
|||||||
assert.equal(row.ipLocation, "江西");
|
assert.equal(row.ipLocation, "江西");
|
||||||
assert.equal(row.followers, 10);
|
assert.equal(row.followers, 10);
|
||||||
assert.equal(row.followersResolved, true);
|
assert.equal(row.followersResolved, true);
|
||||||
|
assert.equal(row.gender, "女");
|
||||||
|
assert.equal(row.bio, "分享江西本地生活");
|
||||||
|
assert.deepEqual(row.tags, ["本地生活", "美食探店"]);
|
||||||
assert.deepEqual(resourceImportMissingFields(row), []);
|
assert.deepEqual(resourceImportMissingFields(row), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("validates optional gender", () => {
|
||||||
|
const csv = [
|
||||||
|
"账号链接,性别,标签",
|
||||||
|
"https://www.xiaohongshu.com/user/profile/abc123,其他,美食探店",
|
||||||
|
].join("\n");
|
||||||
|
const [row] = parseResourceImportFile(
|
||||||
|
"resources.csv",
|
||||||
|
new TextEncoder().encode(csv),
|
||||||
|
);
|
||||||
|
assert.match(row.errors.join(";"), /性别格式不正确/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects more than five tags in one optional tag cell", () => {
|
||||||
|
const csv = [
|
||||||
|
"账号链接,标签",
|
||||||
|
'https://www.xiaohongshu.com/user/profile/abc123,"美食探店,旅游出行,数码产品,本地生活,婚嫁备婚,美妆护肤"',
|
||||||
|
].join("\n");
|
||||||
|
const [row] = parseResourceImportFile(
|
||||||
|
"resources.csv",
|
||||||
|
new TextEncoder().encode(csv),
|
||||||
|
);
|
||||||
|
assert.match(row.errors.join(";"), /最多填写 5 个标签/);
|
||||||
|
});
|
||||||
|
|
||||||
test("normalizes common follower formats and identifies missing enrichment fields", () => {
|
test("normalizes common follower formats and identifies missing enrichment fields", () => {
|
||||||
assert.deepEqual(parseResourceFollowers("1.3万"), {
|
assert.deepEqual(parseResourceFollowers("1.3万"), {
|
||||||
value: 13_000,
|
value: 13_000,
|
||||||
@@ -88,11 +184,49 @@ test("parses the first matching worksheet from an XLSX workbook", () => {
|
|||||||
assert.equal(rows[0].followersResolved, false);
|
assert.equal(rows[0].followersResolved, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps values in their columns after a self-closing blank XLSX cell", () => {
|
||||||
|
const workbook = buildRecoveryWorkbook({
|
||||||
|
sheetName: "KOC资源导入",
|
||||||
|
headers: ["账号主页", "账号昵称", "账号ID", "IP属地", "粉丝数", "合作来源"],
|
||||||
|
columnWidths: [48, 24, 20, 16, 14, 24],
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
cells: [
|
||||||
|
"https://www.xiaohongshu.com/user/profile/blank-ip-cell",
|
||||||
|
"空白IP账号",
|
||||||
|
"123456789",
|
||||||
|
"",
|
||||||
|
10,
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
images: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const entries = unzipSync(workbook);
|
||||||
|
const sheetPath = "xl/worksheets/sheet1.xml";
|
||||||
|
const sheetXml = strFromU8(entries[sheetPath]);
|
||||||
|
entries[sheetPath] = strToU8(
|
||||||
|
sheetXml.replace('<c r="E2"', '<c r="D2"/><c r="E2"'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [row] = parseResourceImportFile(
|
||||||
|
"self-closing-blank.xlsx",
|
||||||
|
zipSync(entries),
|
||||||
|
);
|
||||||
|
assert.equal(row.ipLocation, "");
|
||||||
|
assert.equal(row.followers, 10);
|
||||||
|
assert.equal(row.followersResolved, true);
|
||||||
|
assert.deepEqual(row.errors, []);
|
||||||
|
});
|
||||||
|
|
||||||
test("reports invalid required fields without hiding valid rows", () => {
|
test("reports invalid required fields without hiding valid rows", () => {
|
||||||
const csv = "账号主页,合作来源\n,历史资源\nhttps://www.douyin.com/user/demo,社群";
|
const csv = "账号主页,合作来源\n,历史资源\nhttps://www.douyin.com/user/demo,社群\nhttps://example.com/user/demo,其他";
|
||||||
const rows = parseResourceImportFile("resources.csv", new TextEncoder().encode(csv));
|
const rows = parseResourceImportFile("resources.csv", new TextEncoder().encode(csv));
|
||||||
assert.match(rows[0].errors.join(";"), /账号主页不能为空/);
|
assert.match(rows[0].errors.join(";"), /账号主页不能为空/);
|
||||||
assert.match(rows[1].errors.join(";"), /仅支持小红书账号主页/);
|
assert.equal(rows[1].platform, "抖音");
|
||||||
|
assert.equal(rows[1].errors.length, 0);
|
||||||
|
assert.match(rows[2].errors.join(";"), /仅支持小红书或抖音账号主页/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects invalid optional follower values without requiring other optional fields", () => {
|
test("rejects invalid optional follower values without requiring other optional fields", () => {
|
||||||
@@ -107,6 +241,18 @@ test("rejects invalid optional follower values without requiring other optional
|
|||||||
assert.match(row.errors.join(";"), /粉丝数格式不正确/);
|
assert.match(row.errors.join(";"), /粉丝数格式不正确/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rejects a numeric value entered as an IP location", () => {
|
||||||
|
const csv = [
|
||||||
|
"账号链接,IP属地,粉丝数",
|
||||||
|
"https://www.xiaohongshu.com/user/profile/abc123,10,100",
|
||||||
|
].join("\n");
|
||||||
|
const [row] = parseResourceImportFile(
|
||||||
|
"resources.csv",
|
||||||
|
new TextEncoder().encode(csv),
|
||||||
|
);
|
||||||
|
assert.match(row.errors.join(";"), /IP属地格式不正确/);
|
||||||
|
});
|
||||||
|
|
||||||
test("normalizes profile URLs and merges cooperation sources", () => {
|
test("normalizes profile URLs and merges cooperation sources", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
normalizeProfileUrl("https://www.xiaohongshu.com/user/profile/abc/?foo=1#top"),
|
normalizeProfileUrl("https://www.xiaohongshu.com/user/profile/abc/?foo=1#top"),
|
||||||
@@ -117,16 +263,3 @@ test("normalizes profile URLs and merges cooperation sources", () => {
|
|||||||
"林林社群、木子、历史表格",
|
"林林社群、木子、历史表格",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parses and normalizes the optional tags column", () => {
|
|
||||||
const csv = [
|
|
||||||
"账号链接,标签",
|
|
||||||
'"https://www.xiaohongshu.com/user/profile/abc123","美食探店, 旅游出行, 美食探店"',
|
|
||||||
].join("\n");
|
|
||||||
const [row] = parseResourceImportFile(
|
|
||||||
"resources.csv",
|
|
||||||
new TextEncoder().encode(csv),
|
|
||||||
);
|
|
||||||
assert.equal(row.tags, "美食探店、旅游出行");
|
|
||||||
assert.equal(mergeTags("美食探店", "旅游出行;数码汽车"), "美食探店、旅游出行、数码汽车");
|
|
||||||
});
|
|
||||||
|
|||||||
98
tests/workbook-image.test.mjs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import sharp from "sharp";
|
||||||
|
import { normalizeWorkbookImage } from "../lib/workbook-image.ts";
|
||||||
|
|
||||||
|
test("bakes EXIF orientation into exported workbook image pixels", async () => {
|
||||||
|
const source = await sharp({
|
||||||
|
create: {
|
||||||
|
width: 8,
|
||||||
|
height: 4,
|
||||||
|
channels: 3,
|
||||||
|
background: "#e95420",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.jpeg()
|
||||||
|
.withMetadata({ orientation: 6 })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const normalized = await normalizeWorkbookImage({
|
||||||
|
bytes: new Uint8Array(source),
|
||||||
|
contentType: "image/jpeg",
|
||||||
|
width: 8,
|
||||||
|
height: 4,
|
||||||
|
description: "手机照片",
|
||||||
|
});
|
||||||
|
const metadata = await sharp(normalized.bytes).metadata();
|
||||||
|
|
||||||
|
assert.equal(normalized.contentType, "image/png");
|
||||||
|
assert.equal(normalized.width, 4);
|
||||||
|
assert.equal(normalized.height, 8);
|
||||||
|
assert.equal(metadata.width, 4);
|
||||||
|
assert.equal(metadata.height, 8);
|
||||||
|
assert.equal(metadata.orientation, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps unsupported image bytes unchanged", async () => {
|
||||||
|
const bytes = Uint8Array.from([1, 2, 3]);
|
||||||
|
const normalized = await normalizeWorkbookImage({
|
||||||
|
bytes,
|
||||||
|
contentType: "application/octet-stream",
|
||||||
|
description: "未知文件",
|
||||||
|
});
|
||||||
|
assert.equal(normalized.bytes, bytes);
|
||||||
|
assert.equal(normalized.contentType, "application/octet-stream");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes recognizable images even when storage metadata has no image MIME type", async () => {
|
||||||
|
const source = await sharp({
|
||||||
|
create: {
|
||||||
|
width: 8,
|
||||||
|
height: 4,
|
||||||
|
channels: 3,
|
||||||
|
background: "#22c55e",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.jpeg()
|
||||||
|
.withMetadata({ orientation: 6 })
|
||||||
|
.toBuffer();
|
||||||
|
const normalized = await normalizeWorkbookImage({
|
||||||
|
bytes: new Uint8Array(source),
|
||||||
|
contentType: "application/octet-stream",
|
||||||
|
description: "方向元数据缺失测试",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(normalized.contentType, "image/png");
|
||||||
|
assert.equal(normalized.width, 4);
|
||||||
|
assert.equal(normalized.height, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can downsize full-resolution source images for compact workbook exports", async () => {
|
||||||
|
const source = await sharp({
|
||||||
|
create: {
|
||||||
|
width: 4000,
|
||||||
|
height: 3000,
|
||||||
|
channels: 3,
|
||||||
|
background: "#d4a72c",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.png({ compressionLevel: 0 })
|
||||||
|
.toBuffer();
|
||||||
|
const normalized = await normalizeWorkbookImage(
|
||||||
|
{
|
||||||
|
bytes: new Uint8Array(source),
|
||||||
|
contentType: "image/png",
|
||||||
|
width: 4000,
|
||||||
|
height: 3000,
|
||||||
|
description: "批量回填原图",
|
||||||
|
},
|
||||||
|
{ maxDimension: 1600, outputFormat: "jpeg", jpegQuality: 82 },
|
||||||
|
);
|
||||||
|
const metadata = await sharp(normalized.bytes).metadata();
|
||||||
|
|
||||||
|
assert.equal(normalized.contentType, "image/jpeg");
|
||||||
|
assert.equal(normalized.width, 1600);
|
||||||
|
assert.equal(normalized.height, 1200);
|
||||||
|
assert.equal(metadata.format, "jpeg");
|
||||||
|
assert.ok(normalized.bytes.byteLength < source.byteLength / 20);
|
||||||
|
});
|
||||||