Files
koc-loop/lib/collection-service.ts
ABAPPLO 87f4d4066d feat: KOC详情弹窗 + 任务中心单入口改版 + 资源池默认合作方/采集兜底
KOC 详情弹窗(合作方/数据回收/工作台三处入口):
- 新增 KocDetailModal:基本信息 / 绑定KOC账号画像 / 参与任务记录 / 履约漏斗
- 工作台最近分发、回收队列账号名、截图回收合作方名、合作方页名称、分发详情头部合作方均可点开
- 分发表行点击仍打开分发详情(stopPropagation 隔离)

任务中心单入口改版(对齐"单任务双阶段"模型):
- 任务卡发布/回收面板整块可点,全卡仅保留一个「查看明细」,回收面板重复按钮移除
- 详情页自述身份:动态 label(任务详情·分发与回收一体/仅分发)+ 阶段徽章(含回收·截止/未开通回收)
- 内容分发表新增阶段筛选 chips(全部/待发布/已发布待数据/待收截图,未开通回收不显示截图项),筛选落空给专用空态文案
- 数据回收页入口文案改为「选择一个任务进入回收队列」,全站不再区分发布/回收两种任务类型

资源池 KOC 默认加入合作方 + 采集兜底:
- partners 新增 released_account_id(mysql/0023):显式解绑/被顶替的账号不再被默认合作方同步回填
- 任务未设置采集计划时,默认按发布时间起连续采集 7 天,等待文案显示「等待D1 · 默认7天」
2026-10-12 11:11:31 +08:00

619 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
collectMetricsFromMcp,
type CollectionMcpConfig,
} from "./mcp-collection-client";
import { uid } from "./mvp-db";
import type { DatabaseClient, DatabaseStatement } from "./database";
import { refreshTaskStatus } from "./task-service";
type DistributionForCollection = {
id: string;
task_id: string;
publish_url: string | null;
ocr_status: string;
platform: string;
};
type ScheduledTask = {
id: string;
task_type: string | null;
collection_start_date: string | null;
collection_days: string;
};
type CollectionSource = "automatic" | "catchup" | "manual";
function userFacingCollectionError(error: unknown) {
const message = error instanceof Error ? error.message : String(error ?? "");
const normalized = message.toLowerCase();
if (
/cookie|登录|登陆|授权|access.?token|未登录|未授权|401|403/.test(
normalized,
)
) {
return "采集 Cookie 已过期或无权限";
}
if (
/链接|link|url|404|not found|不存在|删除|失效|无法识别.*笔记/.test(
normalized,
)
) {
return "笔记链接失效或不可访问";
}
if (
/超时|timeout|fetch failed|network|502|503|暂时不可用|响应空/.test(
normalized,
)
) {
return "采集服务暂时不可用";
}
return "笔记链接失效或采集 Cookie 过期";
}
function utcDay(value: string) {
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
}
function dateForScheduleDay(startDate: string, scheduleDay: number) {
const start = utcDay(startDate);
if (start === null) return null;
return new Date(start + (scheduleDay - 1) * 86_400_000)
.toISOString()
.slice(0, 10);
}
function parseDays(value: string | null | undefined) {
try {
const parsed = JSON.parse(value ?? "[]") as unknown;
if (!Array.isArray(parsed)) return [];
return [...new Set(parsed.map(Number))]
.filter((day) => Number.isInteger(day) && day >= 1 && day <= 7)
.sort((a, b) => a - b);
} catch {
return [];
}
}
export function parseCollectionDays(value: string | null | undefined) {
return parseDays(value);
}
// 数据回收(自动采集)兜底:任务未设置采集日时默认连续采集 7 天。
export const DEFAULT_COLLECTION_DAYS = [1, 2, 3, 4, 5, 6, 7];
export function effectiveCollectionDays(value: string | null | undefined) {
const days = parseDays(value);
return days.length > 0 ? days : [...DEFAULT_COLLECTION_DAYS];
}
export function shanghaiDateFromTimestamp(timestamp: number) {
return new Date(timestamp + 8 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
}
// publish_time 等列以 UTC 文本存储(CURRENT_TIMESTAMP),统一换算成上海日期。
function shanghaiDateFromSqlTimestamp(value: string | null | undefined) {
if (!value) return null;
const normalized = value.includes("T")
? value
: `${value.replace(" ", "T")}Z`;
const epoch = Date.parse(normalized);
if (Number.isNaN(epoch)) return null;
return shanghaiDateFromTimestamp(epoch);
}
function shanghaiHourFromTimestamp(timestamp: number) {
return Number(
new Date(timestamp + 8 * 60 * 60 * 1000)
.toISOString()
.slice(11, 13),
);
}
export function isCollectionScheduleDue(
scheduledDate: string,
timestamp: number,
) {
const currentDate = shanghaiDateFromTimestamp(timestamp);
const currentHour = shanghaiHourFromTimestamp(timestamp);
return (
scheduledDate < currentDate ||
(scheduledDate === currentDate && currentHour >= 9)
);
}
function dueSchedules(
startDate: string,
days: number[],
timestamp: number,
) {
return days
.map((scheduleDay) => ({
scheduleDay,
scheduledDate: dateForScheduleDay(startDate, scheduleDay),
}))
.filter(
(
value,
): value is { scheduleDay: number; scheduledDate: string } =>
Boolean(
value.scheduledDate &&
isCollectionScheduleDue(value.scheduledDate, timestamp),
),
);
}
export async function createCollectionRunTasks(
db: DatabaseClient,
taskId: string,
startDate: string,
days: number[],
) {
const normalizedDays = [...new Set(days)]
.filter((day) => Number.isInteger(day) && day >= 1 && day <= 7)
.sort((a, b) => a - b);
const distributions = await db
.prepare(
`SELECT id FROM distributions
WHERE task_id = ?
AND publish_url IS NOT NULL
AND publish_url != ''`,
)
.bind(taskId)
.all<{ id: string }>();
await db
.prepare(
`DELETE FROM collection_runs
WHERE task_id = ?
AND status = 'pending'`,
)
.bind(taskId)
.run();
const statements: DatabaseStatement[] = [];
for (const distribution of distributions.results) {
for (const scheduleDay of normalizedDays) {
const scheduledDate = dateForScheduleDay(startDate, scheduleDay);
if (!scheduledDate) continue;
statements.push(
db
.prepare(
`INSERT OR IGNORE INTO collection_runs
(id, task_id, distribution_id, scheduled_date, schedule_day,
scheduled_at, status, status_description)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)`,
)
.bind(
uid("run"),
taskId,
distribution.id,
scheduledDate,
scheduleDay,
`${scheduledDate}T09:00:00+08:00`,
`等待第${scheduleDay}天 09:00自动采集`,
),
);
}
}
for (let index = 0; index < statements.length; index += 100) {
await db.batch(statements.slice(index, index + 100));
}
return {
distributions: distributions.results.length,
days: normalizedDays.length,
tasks: statements.length,
};
}
export async function collectDistributionMetrics(
db: DatabaseClient,
distributionId: string,
scheduledDate: string,
scheduleDay: number | null,
source: CollectionSource,
mcpConfig: CollectionMcpConfig,
) {
const current = await db
.prepare(
`SELECT d.*, t.platform
FROM distributions d
JOIN tasks t ON t.id = d.task_id
WHERE d.id = ?`,
)
.bind(distributionId)
.first<DistributionForCollection>();
if (!current) throw new Error("分发记录不存在");
if (!current.publish_url) throw new Error("作品尚未回填发布链接");
const scheduledAt = `${scheduledDate}T09:00:00+08:00`;
const runId = uid("run");
await db
.prepare(
`INSERT OR IGNORE INTO collection_runs
(id, task_id, distribution_id, scheduled_date, schedule_day,
scheduled_at, status, status_description)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)`,
)
.bind(
runId,
current.task_id,
distributionId,
scheduledDate,
scheduleDay,
scheduledAt,
source === "automatic"
? "等待自动采集"
: source === "catchup"
? "等待自动追采"
: "等待手动采集",
)
.run();
const run = await db
.prepare(
`SELECT id, status FROM collection_runs
WHERE distribution_id = ? AND scheduled_date = ?`,
)
.bind(distributionId, scheduledDate)
.first<{ id: string; status: string }>();
if (!run) throw new Error("采集任务创建失败");
// 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 =
source === "automatic"
? `第${scheduleDay ?? "—"}天自动采集中`
: source === "catchup"
? `第${scheduleDay ?? "—"}天自动追采中`
: "正在手动采集";
await db.batch([
db
.prepare(
`UPDATE collection_runs
SET schedule_day = COALESCE(schedule_day, ?),
scheduled_at = ?,
status = 'collecting',
status_description = ?,
started_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(scheduleDay, scheduledAt, collectingDescription, run.id),
db
.prepare(
`UPDATE distributions
SET collection_status = 'collecting',
collection_status_description = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(collectingDescription, distributionId),
]);
try {
const { likes, comments, collects, shares } =
await collectMetricsFromMcp(
current.publish_url,
current.platform === "抖音" ? "抖音" : "小红书",
mcpConfig,
);
const dayWeight = scheduleDay ?? 1;
const successDescription =
source === "automatic"
? `成功 · 第${dayWeight}天 09:00自动采集`
: source === "catchup"
? `成功 · 第${dayWeight}天自动追采`
: "成功 · 手动采集";
await db.batch([
db
.prepare(
`UPDATE collection_runs
SET status = 'success',
likes = ?,
comments = ?,
collects = ?,
shares = ?,
status_description = ?,
completed_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(
likes,
comments,
collects,
shares,
successDescription,
run.id,
),
db
.prepare(
`UPDATE distributions
SET latest_likes = ?,
latest_comments = ?,
latest_collects = ?,
latest_shares = ?,
collection_status = 'success',
collection_status_description = ?,
collection_updated_at = CURRENT_TIMESTAMP,
last_collection_day = ?,
status = CASE
WHEN exposure IS NOT NULL AND views IS NOT NULL THEN 'complete'
ELSE 'collecting'
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(
likes,
comments,
collects,
shares,
successDescription,
scheduleDay,
distributionId,
),
]);
await refreshTaskStatus(current.task_id, db).catch(() => undefined);
return { skipped: false, likes, comments, collects, shares };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
const message = userFacingCollectionError(error);
console.error("[KOC LOOP] collection failed", { detail, distributionId });
await db.batch([
db
.prepare(
`UPDATE collection_runs
SET status = 'failed',
status_description = ?,
completed_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(message, run.id),
db
.prepare(
// 已有历史数据(此前采集成功过)的分行失败时不降级为 failed:
// 保留 success 状态与数据时间,仅在描述里说明本次刷新失败。
`UPDATE distributions
SET collection_status = CASE
WHEN latest_likes IS NULL THEN 'failed'
ELSE 'success'
END,
collection_status_description = CASE
WHEN latest_likes IS NULL THEN ?
ELSE ?
END,
collection_updated_at = CASE
WHEN latest_likes IS NULL THEN CURRENT_TIMESTAMP
ELSE collection_updated_at
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(message, `${message},沿用上次采集数据`, distributionId),
]);
throw error;
}
}
export async function runScheduledCollections(
db: DatabaseClient,
scheduledTimestamp: number,
mcpConfig: CollectionMcpConfig,
) {
return runDueScheduledCollections(
db,
scheduledTimestamp,
mcpConfig,
"automatic",
);
}
async function distributionIdsNeedingCollection(
db: DatabaseClient,
taskId: string,
scheduledDate: string,
distributionId?: string,
) {
const scoped = distributionId ? "AND d.id = ?" : "";
const { results } = await db
.prepare(
`SELECT d.id FROM distributions d
WHERE d.task_id = ?
AND d.publish_url IS NOT NULL
AND d.publish_url != ''
${scoped}
AND NOT EXISTS (
SELECT 1 FROM collection_runs r
WHERE r.distribution_id = d.id
AND r.scheduled_date = ?
AND (
r.status = 'success'
OR (
r.status = 'collecting'
AND r.started_at >= datetime('now', '-30 minutes')
)
OR (
r.status = 'failed'
AND r.completed_at >= datetime('now', '-15 minutes')
)
)
)`,
)
.bind(...(distributionId ? [taskId, distributionId, scheduledDate] : [taskId, scheduledDate]))
.all<{ id: string }>();
return results.map((row) => row.id);
}
export async function runDueScheduledCollections(
db: DatabaseClient,
timestamp: number,
mcpConfig: CollectionMcpConfig,
source: Extract<CollectionSource, "automatic" | "catchup"> = "catchup",
onlyTaskId?: string,
) {
const currentDate = shanghaiDateFromTimestamp(timestamp);
const tasks = await db
.prepare(
`SELECT id, task_type, collection_start_date, collection_days
FROM tasks
WHERE COALESCE(task_type, 'content_publish') != 'screenshot_collect'
AND (? IS NULL OR id = ?)`,
)
.bind(onlyTaskId ?? null, onlyTaskId ?? null)
.all<ScheduledTask>();
let dueTasks = 0;
let attempted = 0;
let succeeded = 0;
let failed = 0;
const collectDue = async (
taskId: string,
schedules: { scheduleDay: number; scheduledDate: string }[],
distributionId?: string,
) => {
for (const { scheduleDay, scheduledDate } of schedules) {
dueTasks += 1;
const ids = await distributionIdsNeedingCollection(
db,
taskId,
scheduledDate,
distributionId,
);
for (const id of ids) {
attempted += 1;
try {
await collectDistributionMetrics(
db,
id,
scheduledDate,
scheduleDay,
source,
mcpConfig,
);
succeeded += 1;
} catch {
failed += 1;
}
}
}
};
for (const task of tasks.results) {
const parsedDays = parseDays(task.collection_days);
if (task.collection_start_date && parsedDays.length > 0) {
await collectDue(
task.id,
dueSchedules(task.collection_start_date, parsedDays, timestamp),
);
continue;
}
// 未设置采集计划:默认按每篇作品的发布时间起连续采集 7 天。
const days = effectiveCollectionDays(task.collection_days);
const published = await db
.prepare(
`SELECT d.id, COALESCE(d.publish_time, d.claimed_at) AS publish_anchor
FROM distributions d
WHERE d.task_id = ?
AND d.publish_url IS NOT NULL
AND d.publish_url != ''`,
)
.bind(task.id)
.all<{ id: string; publish_anchor: string | null }>();
for (const distribution of published.results) {
const anchorDate = shanghaiDateFromSqlTimestamp(
distribution.publish_anchor,
);
if (!anchorDate) continue;
const windowEndDate = dateForScheduleDay(
anchorDate,
days[days.length - 1],
);
// 默认窗口已过(发布超过 7 天且从未配置计划)不再补采历史天,
// 避免 D1 快照与真实发布时间脱节;确需补采时由管理员设置明确计划。
if (!windowEndDate || currentDate > windowEndDate) continue;
await collectDue(
task.id,
dueSchedules(anchorDate, days, timestamp),
distribution.id,
);
}
}
return { currentDate, dueTasks, attempted, succeeded, failed };
}
export async function retryFailedCollections(
db: DatabaseClient,
taskId: string,
mcpConfig: CollectionMcpConfig,
) {
const failedDistributions = await db
.prepare(
`SELECT
d.id,
COALESCE(
(
SELECT r.scheduled_date
FROM collection_runs r
WHERE r.distribution_id = d.id
AND r.status = 'failed'
ORDER BY COALESCE(r.completed_at, r.created_at) DESC
LIMIT 1
),
?
) AS scheduled_date,
(
SELECT r.schedule_day
FROM collection_runs r
WHERE r.distribution_id = d.id
AND r.status = 'failed'
ORDER BY COALESCE(r.completed_at, r.created_at) DESC
LIMIT 1
) AS schedule_day
FROM distributions d
WHERE d.task_id = ?
AND d.publish_url IS NOT NULL
AND d.publish_url != ''
AND d.collection_status = 'failed'
ORDER BY COALESCE(d.collection_updated_at, d.updated_at)
LIMIT 50`,
)
.bind(shanghaiDateFromTimestamp(Date.now()), taskId)
.all<{
id: string;
scheduled_date: string;
schedule_day: number | null;
}>();
let succeeded = 0;
let failed = 0;
for (const distribution of failedDistributions.results) {
try {
await collectDistributionMetrics(
db,
distribution.id,
distribution.scheduled_date,
distribution.schedule_day,
"manual",
mcpConfig,
);
succeeded += 1;
} catch {
failed += 1;
}
}
return {
attempted: failedDistributions.results.length,
succeeded,
failed,
};
}