4 Commits

Author SHA1 Message Date
ABAPPLO
f2ac751c4c feat: 接入企业微信通知(临期催办 + 群机器人汇总)
新增 lib/wecom-client.ts 与 lib/wecom-notifier-service.ts,支持
群机器人 webhook 与应用消息双通道;scheduler 加入临期 N 天催办
与每日管理员汇总;action 路由补 bind_wecom_external_id 与
send_test_wecom 两个管理端动作,配套测试。
2026-08-18 17:05:16 +08:00
巫凤萍
cac6c5e83b fix: 修复回填更新与截图刷新 2026-08-16 22:22:50 +08:00
巫凤萍
74671a9b9f docs: 更新 main 私有化部署指南 2026-08-15 03:57:50 +08:00
巫凤萍
f37d05dd88 feat: 完善视频任务与 KOC 资源库 2026-08-15 03:53:09 +08:00
70 changed files with 6697 additions and 713 deletions

1
.gitignore vendored
View File

@@ -39,6 +39,7 @@ yarn-error.log*
# typescript # typescript
next-env.d.ts next-env.d.ts
*.tsbuildinfo
/dist/ /dist/
/.wrangler/ /.wrangler/
/outputs/ /outputs/

View File

@@ -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

View File

@@ -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>IDIP属地</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>
); );

View File

@@ -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();

View File

@@ -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,

View 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);
}

View File

@@ -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 },
); );
} }

View File

@@ -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 });
} }

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 : "导入失败";

View File

@@ -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%;

View File

@@ -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 }),

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

BIN
design-qa-inline-filter.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -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

View File

@@ -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 的网络 |

View File

@@ -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 由两个独立站点组成:

View 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(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/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,
};
}

View File

@@ -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;
} }

View File

@@ -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>
); );
} }

View File

@@ -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"/);

View File

@@ -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) {

View File

@@ -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 : "公开数据采集失败";

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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 },
}, },

View File

@@ -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) {

View 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(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/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;
}

View File

@@ -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}`,
)}`; )}`;

View File

@@ -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 "";
}

View File

@@ -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;

View File

@@ -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}`);
}

View File

@@ -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
View 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
View 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;
}
}

View File

@@ -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),

View File

@@ -1,2 +0,0 @@
ALTER TABLE accounts
ADD COLUMN tags VARCHAR(500) NOT NULL DEFAULT '' AFTER cooperation_source;

View 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;

View 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;

View File

@@ -0,0 +1,2 @@
ALTER TABLE accounts
ADD COLUMN current_contact VARCHAR(255) NOT NULL DEFAULT '' AFTER cooperation_source;

View File

@@ -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
View File

@@ -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"
}, },

View File

@@ -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": {

Binary file not shown.

7
scripts/setup.sh Executable file
View 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
View 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

View File

@@ -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({

View File

@@ -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}`,
);
});

View 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(&quot;SOURCE&quot;,1)</f><v>=DISPIMG(&quot;SOURCE&quot;,1)</v></c>
<c r="F2" t="str"><f>_xlfn.DISPIMG(&quot;PUBLISH&quot;,1)</f><v>=DISPIMG(&quot;PUBLISH&quot;,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);
});

View 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(&quot;SOURCE&quot;,1)</f><v>=DISPIMG(&quot;SOURCE&quot;,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(&quot;PUBLISH&quot;,1)</f><v>=DISPIMG(&quot;PUBLISH&quot;,1)</v></c>
<c r="G2" t="str"><f>_xlfn.DISPIMG(&quot;CREATOR&quot;,1)</f><v>=DISPIMG(&quot;CREATOR&quot;,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);
});

View File

@@ -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", () => {

View File

@@ -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 () => {

View File

@@ -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("美食探店", "旅游出行;数码汽车"), "美食探店、旅游出行、数码汽车");
});

View 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);
});