Expand KOC LOOP MCP operations
This commit is contained in:
75
lib/mcp-export-token.ts
Normal file
75
lib/mcp-export-token.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { ensureSchema, getRawDb } from "./mvp-db";
|
||||
|
||||
export type McpExportKind = "recovery" | "resources";
|
||||
|
||||
type ExportTokenRow = {
|
||||
kind: McpExportKind;
|
||||
payload: string;
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
function sqliteTimestamp(date: Date) {
|
||||
return date.toISOString().replace("T", " ").slice(0, 19);
|
||||
}
|
||||
|
||||
async function digest(value: string) {
|
||||
const bytes = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(value),
|
||||
);
|
||||
return [...new Uint8Array(bytes)]
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function issueMcpExportToken(
|
||||
kind: McpExportKind,
|
||||
payload: Record<string, unknown>,
|
||||
ttlMinutes = 15,
|
||||
) {
|
||||
await ensureSchema();
|
||||
const db = getRawDb();
|
||||
const token = `${crypto.randomUUID().replaceAll("-", "")}${crypto
|
||||
.randomUUID()
|
||||
.replaceAll("-", "")}`;
|
||||
const expiresAt = new Date(
|
||||
Date.now() + Math.max(1, Math.min(60, ttlMinutes)) * 60_000,
|
||||
);
|
||||
await db.prepare("DELETE FROM mcp_export_tokens WHERE expires_at <= CURRENT_TIMESTAMP").run();
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO mcp_export_tokens (token_hash, kind, payload, expires_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(await digest(token), kind, JSON.stringify(payload), sqliteTimestamp(expiresAt))
|
||||
.run();
|
||||
return { token, expiresAt: expiresAt.toISOString() };
|
||||
}
|
||||
|
||||
export async function consumeMcpExportToken(
|
||||
token: string,
|
||||
expectedKind: McpExportKind,
|
||||
) {
|
||||
if (!token || token.length < 32) return null;
|
||||
await ensureSchema();
|
||||
const db = getRawDb();
|
||||
const tokenHash = await digest(token);
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT kind, payload, expires_at
|
||||
FROM mcp_export_tokens
|
||||
WHERE token_hash = ? AND expires_at > CURRENT_TIMESTAMP`,
|
||||
)
|
||||
.bind(tokenHash)
|
||||
.first<ExportTokenRow>();
|
||||
if (!row || row.kind !== expectedKind) return null;
|
||||
await db
|
||||
.prepare("DELETE FROM mcp_export_tokens WHERE token_hash = ?")
|
||||
.bind(tokenHash)
|
||||
.run();
|
||||
try {
|
||||
return JSON.parse(row.payload) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
464
lib/mcp-operations.ts
Normal file
464
lib/mcp-operations.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
import { enrichDistributionAccount } from "./account-enrichment-service";
|
||||
import {
|
||||
collectDistributionMetrics,
|
||||
createCollectionRunTasks,
|
||||
retryFailedCollections,
|
||||
runDueScheduledCollections,
|
||||
shanghaiDateFromTimestamp,
|
||||
} from "./collection-service";
|
||||
import { issueMcpExportToken } from "./mcp-export-token";
|
||||
import {
|
||||
resolveCollectionMcpConfig,
|
||||
type CollectionMcpBindings,
|
||||
} from "./mcp-collection-client";
|
||||
import { ensureSchema, getRawDb } from "./mvp-db";
|
||||
import { extractXhsPublishUrl } from "./publish-url";
|
||||
import { buildClaimUrl } from "./task-service";
|
||||
|
||||
export type McpOperationBindings = CollectionMcpBindings & {
|
||||
KOC_PORTAL_URL?: string;
|
||||
};
|
||||
|
||||
type Pagination = { limit?: number; offset?: number };
|
||||
type ResourceFilters = Pagination & {
|
||||
query?: string;
|
||||
ipLocation?: string;
|
||||
cooperationSource?: string;
|
||||
platform?: string;
|
||||
};
|
||||
|
||||
function page(input: Pagination) {
|
||||
return {
|
||||
limit: Math.max(1, Math.min(200, Math.floor(input.limit ?? 50))),
|
||||
offset: Math.max(0, Math.floor(input.offset ?? 0)),
|
||||
};
|
||||
}
|
||||
|
||||
function like(value: string) {
|
||||
return `%${value.replace(/[\\%_]/g, "\\$&")}%`;
|
||||
}
|
||||
|
||||
function parseJsonArray(value: unknown) {
|
||||
try {
|
||||
const parsed = JSON.parse(String(value ?? "[]"));
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function taskExists(taskId: string) {
|
||||
const task = await getRawDb()
|
||||
.prepare("SELECT id FROM tasks WHERE id = ?")
|
||||
.bind(taskId)
|
||||
.first<{ id: string }>();
|
||||
if (!task) throw new Error("任务不存在");
|
||||
}
|
||||
|
||||
export async function taskList(
|
||||
input: Pagination & { query?: string; status?: string },
|
||||
portalUrl: string,
|
||||
) {
|
||||
await ensureSchema();
|
||||
const { limit, offset } = page(input);
|
||||
const conditions: string[] = [];
|
||||
const bindings: unknown[] = [];
|
||||
if (input.query?.trim()) {
|
||||
conditions.push("(t.name LIKE ? ESCAPE '\\' OR t.brand LIKE ? ESCAPE '\\')");
|
||||
const pattern = like(input.query.trim());
|
||||
bindings.push(pattern, pattern);
|
||||
}
|
||||
if (input.status?.trim() && input.status !== "all") {
|
||||
conditions.push("t.status = ?");
|
||||
bindings.push(input.status.trim());
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const db = getRawDb();
|
||||
const [rows, count] = await Promise.all([
|
||||
db
|
||||
.prepare(
|
||||
`SELECT t.*,
|
||||
(SELECT COUNT(*) FROM contents c WHERE c.task_id = t.id) AS note_count,
|
||||
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id) AS claimed_count,
|
||||
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND COALESCE(d.publish_url, '') != '') AS published_count,
|
||||
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND d.exposure IS NOT NULL AND d.views IS NOT NULL) AS day7_count
|
||||
FROM tasks t ${where}
|
||||
ORDER BY t.created_at DESC LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.bind(...bindings, limit, offset)
|
||||
.all<Record<string, unknown>>(),
|
||||
db
|
||||
.prepare(`SELECT COUNT(*) AS total FROM tasks t ${where}`)
|
||||
.bind(...bindings)
|
||||
.first<{ total: number }>(),
|
||||
]);
|
||||
return {
|
||||
total: count?.total ?? 0,
|
||||
limit,
|
||||
offset,
|
||||
tasks: rows.results.map((row) => ({
|
||||
...row,
|
||||
collection_days: parseJsonArray(row.collection_days),
|
||||
claim_url:
|
||||
portalUrl && row.share_token
|
||||
? buildClaimUrl(portalUrl, String(row.share_token))
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function taskGet(taskId: string, portalUrl: string) {
|
||||
await ensureSchema();
|
||||
const db = getRawDb();
|
||||
const task = await db
|
||||
.prepare("SELECT * FROM tasks WHERE id = ?")
|
||||
.bind(taskId)
|
||||
.first<Record<string, unknown>>();
|
||||
if (!task) throw new Error("任务不存在");
|
||||
const [notes, claims, runs] = await Promise.all([
|
||||
db
|
||||
.prepare(
|
||||
`SELECT c.id AS content_id, c.source_row, c.title, c.body, c.image_assets, c.status AS content_status,
|
||||
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.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,
|
||||
p.name AS cooperation_source, cl.claimant_name
|
||||
FROM contents c
|
||||
LEFT JOIN distributions d ON d.content_id = c.id
|
||||
LEFT JOIN accounts a ON a.id = d.account_id
|
||||
LEFT JOIN partners p ON p.id = d.partner_id
|
||||
LEFT JOIN claims cl ON cl.id = d.claim_id
|
||||
WHERE c.task_id = ?
|
||||
ORDER BY COALESCE(c.source_row, 999999), c.created_at, d.claimed_at`,
|
||||
)
|
||||
.bind(taskId)
|
||||
.all<Record<string, unknown>>(),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT cl.*, p.name AS partner_name,
|
||||
(SELECT COUNT(*) FROM distributions d WHERE d.claim_id = cl.id AND COALESCE(d.publish_url, '') != '') AS published_count
|
||||
FROM claims cl JOIN partners p ON p.id = cl.partner_id
|
||||
WHERE cl.task_id = ? ORDER BY cl.created_at DESC`,
|
||||
)
|
||||
.bind(taskId)
|
||||
.all<Record<string, unknown>>(),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT * FROM collection_runs WHERE task_id = ?
|
||||
ORDER BY scheduled_date DESC, created_at DESC LIMIT 500`,
|
||||
)
|
||||
.bind(taskId)
|
||||
.all<Record<string, unknown>>(),
|
||||
]);
|
||||
return {
|
||||
task: {
|
||||
...task,
|
||||
collection_days: parseJsonArray(task.collection_days),
|
||||
claim_url:
|
||||
portalUrl && task.share_token
|
||||
? buildClaimUrl(portalUrl, String(task.share_token))
|
||||
: null,
|
||||
},
|
||||
notes: notes.results.map((row) => ({
|
||||
...row,
|
||||
image_assets: parseJsonArray(row.image_assets),
|
||||
total_interactions:
|
||||
row.latest_likes == null
|
||||
? null
|
||||
: Number(row.latest_likes) +
|
||||
Number(row.latest_comments ?? 0) +
|
||||
Number(row.latest_collects ?? 0),
|
||||
})),
|
||||
claims: claims.results,
|
||||
collection_runs: runs.results,
|
||||
};
|
||||
}
|
||||
|
||||
export async function recoveryList(
|
||||
input: Pagination & {
|
||||
taskId?: string;
|
||||
stage?: "all" | "published" | "unfilled" | "waiting_day7" | "day7_due";
|
||||
},
|
||||
) {
|
||||
await ensureSchema();
|
||||
const { limit, offset } = page(input);
|
||||
const db = getRawDb();
|
||||
const conditions: string[] = [];
|
||||
const bindings: unknown[] = [];
|
||||
if (input.taskId?.trim()) {
|
||||
conditions.push("d.task_id = ?");
|
||||
bindings.push(input.taskId.trim());
|
||||
}
|
||||
const stage = input.stage ?? "all";
|
||||
if (stage === "published") conditions.push("COALESCE(d.publish_url, '') != ''");
|
||||
if (stage === "unfilled") conditions.push("COALESCE(d.publish_url, '') = ''");
|
||||
if (stage === "waiting_day7") {
|
||||
conditions.push("COALESCE(d.publish_url, '') != '' AND (d.exposure IS NULL OR d.views IS NULL)");
|
||||
}
|
||||
if (stage === "day7_due") {
|
||||
conditions.push(
|
||||
"COALESCE(d.publish_url, '') != '' AND (d.exposure IS NULL OR d.views IS NULL) AND datetime(d.publish_time, '+7 days') <= CURRENT_TIMESTAMP",
|
||||
);
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const base = `FROM distributions d
|
||||
JOIN tasks t ON t.id = d.task_id
|
||||
JOIN contents c ON c.id = d.content_id
|
||||
LEFT JOIN accounts a ON a.id = d.account_id
|
||||
LEFT JOIN partners p ON p.id = d.partner_id
|
||||
LEFT JOIN claims cl ON cl.id = d.claim_id ${where}`;
|
||||
const [rows, count] = await Promise.all([
|
||||
db
|
||||
.prepare(
|
||||
`SELECT d.*, t.name AS task_name, c.source_row, c.title,
|
||||
a.nickname AS account_nickname, a.profile_url, p.name AS cooperation_source,
|
||||
cl.claimant_name ${base}
|
||||
ORDER BY COALESCE(d.publish_time, d.claimed_at) DESC LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.bind(...bindings, limit, offset)
|
||||
.all<Record<string, unknown>>(),
|
||||
db
|
||||
.prepare(`SELECT COUNT(*) AS total ${base}`)
|
||||
.bind(...bindings)
|
||||
.first<{ total: number }>(),
|
||||
]);
|
||||
return {
|
||||
total: count?.total ?? 0,
|
||||
limit,
|
||||
offset,
|
||||
items: rows.results.map((row) => ({
|
||||
...row,
|
||||
total_interactions:
|
||||
row.latest_likes == null
|
||||
? null
|
||||
: Number(row.latest_likes) + Number(row.latest_comments ?? 0) + Number(row.latest_collects ?? 0),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function recoveryExport(taskId: string, origin: string) {
|
||||
await ensureSchema();
|
||||
await taskExists(taskId);
|
||||
const issued = await issueMcpExportToken("recovery", { taskId });
|
||||
return {
|
||||
task_id: taskId,
|
||||
download_url: `${origin}/api/recovery-export?token=${encodeURIComponent(issued.token)}`,
|
||||
expires_at: issued.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setCollectionPlan(
|
||||
taskId: string,
|
||||
startDate: string,
|
||||
days: number[],
|
||||
bindings: McpOperationBindings,
|
||||
) {
|
||||
await ensureSchema();
|
||||
await taskExists(taskId);
|
||||
const normalizedDays = [...new Set(days)]
|
||||
.filter((day) => Number.isInteger(day) && day >= 1 && day <= 7)
|
||||
.sort((a, b) => a - b);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(startDate) || Number.isNaN(Date.parse(`${startDate}T00:00:00+08:00`))) {
|
||||
throw new Error("开始采集日期无效");
|
||||
}
|
||||
if (!normalizedDays.length) throw new Error("请至少选择一个采集日");
|
||||
const db = getRawDb();
|
||||
await db.batch([
|
||||
db.prepare(
|
||||
`UPDATE tasks SET collection_start_date = ?, collection_days = ?,
|
||||
collection_schedule_updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
).bind(startDate, JSON.stringify(normalizedDays), taskId),
|
||||
db.prepare(
|
||||
`UPDATE distributions SET
|
||||
collection_status = CASE WHEN latest_likes IS NULL THEN 'scheduled' ELSE collection_status END,
|
||||
collection_status_description = CASE WHEN latest_likes IS NULL THEN ? ELSE collection_status_description END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE task_id = ? AND COALESCE(publish_url, '') != ''`,
|
||||
).bind(`已安排${normalizedDays.length}个采集日,每日10:00执行`, taskId),
|
||||
]);
|
||||
const created = await createCollectionRunTasks(db, taskId, startDate, normalizedDays);
|
||||
const catchup = await runDueScheduledCollections(
|
||||
db,
|
||||
Date.now(),
|
||||
resolveCollectionMcpConfig(bindings),
|
||||
"catchup",
|
||||
taskId,
|
||||
);
|
||||
return { task_id: taskId, start_date: startDate, days: normalizedDays, created, catchup };
|
||||
}
|
||||
|
||||
export async function runDueCollections(taskId: string | undefined, bindings: McpOperationBindings) {
|
||||
await ensureSchema();
|
||||
if (taskId) await taskExists(taskId);
|
||||
return runDueScheduledCollections(
|
||||
getRawDb(),
|
||||
Date.now(),
|
||||
resolveCollectionMcpConfig(bindings),
|
||||
"catchup",
|
||||
taskId,
|
||||
);
|
||||
}
|
||||
|
||||
export async function collectNow(
|
||||
distributionId: string,
|
||||
scheduleDay: number | undefined,
|
||||
bindings: McpOperationBindings,
|
||||
) {
|
||||
await ensureSchema();
|
||||
const day = scheduleDay ?? null;
|
||||
const metrics = await collectDistributionMetrics(
|
||||
getRawDb(),
|
||||
distributionId,
|
||||
shanghaiDateFromTimestamp(Date.now()),
|
||||
day,
|
||||
"manual",
|
||||
resolveCollectionMcpConfig(bindings),
|
||||
);
|
||||
return { distribution_id: distributionId, schedule_day: day, ...metrics };
|
||||
}
|
||||
|
||||
export async function retryFailed(taskId: string | undefined, bindings: McpOperationBindings) {
|
||||
await ensureSchema();
|
||||
const db = getRawDb();
|
||||
const ids = taskId
|
||||
? [taskId]
|
||||
: (
|
||||
await db
|
||||
.prepare(
|
||||
`SELECT DISTINCT task_id FROM distributions
|
||||
WHERE collection_status = 'failed' ORDER BY updated_at DESC LIMIT 20`,
|
||||
)
|
||||
.all<{ task_id: string }>()
|
||||
).results.map((row) => row.task_id);
|
||||
if (taskId) await taskExists(taskId);
|
||||
const results = [];
|
||||
for (const id of ids) {
|
||||
results.push({ task_id: id, ...(await retryFailedCollections(db, id, resolveCollectionMcpConfig(bindings))) });
|
||||
}
|
||||
return {
|
||||
task_count: results.length,
|
||||
attempted: results.reduce((sum, item) => sum + item.attempted, 0),
|
||||
succeeded: results.reduce((sum, item) => sum + item.succeeded, 0),
|
||||
failed: results.reduce((sum, item) => sum + item.failed, 0),
|
||||
tasks: results,
|
||||
};
|
||||
}
|
||||
|
||||
function resourceWhere(input: ResourceFilters) {
|
||||
const conditions: string[] = [];
|
||||
const bindings: unknown[] = [];
|
||||
if (input.query?.trim()) {
|
||||
conditions.push("(a.nickname LIKE ? ESCAPE '\\' OR a.public_account_id LIKE ? ESCAPE '\\')");
|
||||
const pattern = like(input.query.trim());
|
||||
bindings.push(pattern, pattern);
|
||||
}
|
||||
if (input.ipLocation?.trim()) {
|
||||
conditions.push("a.ip_location LIKE ? ESCAPE '\\'");
|
||||
bindings.push(like(input.ipLocation.trim()));
|
||||
}
|
||||
if (input.cooperationSource?.trim()) {
|
||||
conditions.push(
|
||||
`EXISTS (SELECT 1 FROM distributions dx JOIN partners px ON px.id = dx.partner_id
|
||||
WHERE dx.account_id = a.id AND px.name LIKE ? ESCAPE '\\')`,
|
||||
);
|
||||
bindings.push(like(input.cooperationSource.trim()));
|
||||
}
|
||||
if (input.platform?.trim() && input.platform !== "all") {
|
||||
conditions.push("a.platform = ?");
|
||||
bindings.push(input.platform.trim());
|
||||
}
|
||||
return { where: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "", bindings };
|
||||
}
|
||||
|
||||
export async function resourceSearch(input: ResourceFilters) {
|
||||
await ensureSchema();
|
||||
const { limit, offset } = page(input);
|
||||
const { where, bindings } = resourceWhere(input);
|
||||
const db = getRawDb();
|
||||
const select = `SELECT a.*,
|
||||
(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`;
|
||||
const [rows, count] = await Promise.all([
|
||||
db.prepare(`${select} FROM accounts a ${where} ORDER BY a.last_seen_at DESC LIMIT ? OFFSET ?`)
|
||||
.bind(...bindings, limit, offset).all<Record<string, unknown>>(),
|
||||
db.prepare(`SELECT COUNT(*) AS total FROM accounts a ${where}`).bind(...bindings).first<{ total: number }>(),
|
||||
]);
|
||||
return {
|
||||
total: count?.total ?? 0,
|
||||
limit,
|
||||
offset,
|
||||
accounts: rows.results.map((row) => ({
|
||||
...row,
|
||||
cooperation_sources: String(row.cooperation_sources ?? "").split(",").filter(Boolean),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resourceGet(accountId: string) {
|
||||
await ensureSchema();
|
||||
const db = getRawDb();
|
||||
const account = await db.prepare("SELECT * FROM accounts WHERE id = ?").bind(accountId).first<Record<string, unknown>>();
|
||||
if (!account) throw new Error("账号不存在");
|
||||
const history = await db.prepare(
|
||||
`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.exposure, d.views, p.name AS cooperation_source, cl.claimant_name
|
||||
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
|
||||
LEFT JOIN claims cl ON cl.id = d.claim_id
|
||||
WHERE d.account_id = ? ORDER BY COALESCE(d.publish_time, d.claimed_at) DESC`,
|
||||
).bind(accountId).all<Record<string, unknown>>();
|
||||
return { account, cooperation_history: history.results };
|
||||
}
|
||||
|
||||
export async function backfillResourceProfile(
|
||||
input: { distributionId?: string; publishUrl?: string },
|
||||
bindings: McpOperationBindings,
|
||||
) {
|
||||
await ensureSchema();
|
||||
const db = getRawDb();
|
||||
const publishUrl = input.publishUrl ? extractXhsPublishUrl(input.publishUrl) : "";
|
||||
const row = input.distributionId
|
||||
? await db.prepare(
|
||||
`SELECT d.id, d.publish_url, COALESCE(a.nickname, '') AS nickname
|
||||
FROM distributions d LEFT JOIN accounts a ON a.id = d.account_id WHERE d.id = ?`,
|
||||
).bind(input.distributionId).first<{ id: string; publish_url: string | null; nickname: string }>()
|
||||
: publishUrl
|
||||
? await db.prepare(
|
||||
`SELECT d.id, d.publish_url, COALESCE(a.nickname, '') AS nickname
|
||||
FROM distributions d LEFT JOIN accounts a ON a.id = d.account_id
|
||||
WHERE d.publish_url = ? ORDER BY d.updated_at DESC LIMIT 1`,
|
||||
).bind(publishUrl).first<{ id: string; publish_url: string | null; nickname: string }>()
|
||||
: null;
|
||||
if (!row?.publish_url) throw new Error("没有找到可补全的发布记录");
|
||||
const result = await enrichDistributionAccount(
|
||||
db,
|
||||
row.id,
|
||||
row.publish_url,
|
||||
row.nickname,
|
||||
resolveCollectionMcpConfig(bindings),
|
||||
);
|
||||
const account = result.updated && result.accountId
|
||||
? await db.prepare("SELECT * FROM accounts WHERE id = ?").bind(result.accountId).first<Record<string, unknown>>()
|
||||
: null;
|
||||
return { distribution_id: row.id, ...result, account };
|
||||
}
|
||||
|
||||
export async function resourceExport(input: ResourceFilters, origin: string) {
|
||||
const result = await resourceSearch({ ...input, limit: 200, offset: 0 });
|
||||
const allIds: string[] = result.accounts.map((account) => String(account.id));
|
||||
if (result.total > result.accounts.length) {
|
||||
const { where, bindings } = resourceWhere(input);
|
||||
const rows = await getRawDb().prepare(`SELECT a.id FROM accounts a ${where} ORDER BY a.last_seen_at DESC LIMIT 5000`)
|
||||
.bind(...bindings).all<{ id: string }>();
|
||||
allIds.splice(0, allIds.length, ...rows.results.map((row) => row.id));
|
||||
}
|
||||
if (!allIds.length) throw new Error("当前筛选结果为空");
|
||||
const issued = await issueMcpExportToken("resources", { accountIds: allIds });
|
||||
return {
|
||||
account_count: allIds.length,
|
||||
download_url: `${origin}/api/resources-export?token=${encodeURIComponent(issued.token)}`,
|
||||
expires_at: issued.expiresAt,
|
||||
};
|
||||
}
|
||||
247
lib/mcp-tools.ts
Normal file
247
lib/mcp-tools.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { type McpServer } from "@modelcontextprotocol/server";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
backfillResourceProfile,
|
||||
collectNow,
|
||||
recoveryExport,
|
||||
recoveryList,
|
||||
resourceExport,
|
||||
resourceGet,
|
||||
resourceSearch,
|
||||
retryFailed,
|
||||
runDueCollections,
|
||||
setCollectionPlan,
|
||||
taskGet,
|
||||
taskList,
|
||||
type McpOperationBindings,
|
||||
} from "./mcp-operations";
|
||||
|
||||
type Options = {
|
||||
bindings: McpOperationBindings;
|
||||
origin: string;
|
||||
};
|
||||
|
||||
function result(label: string, data: unknown) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: label }],
|
||||
structuredContent: data as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
function errorResult(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "操作失败,请稍后重试";
|
||||
return {
|
||||
isError: true as const,
|
||||
content: [{ type: "text" as const, text: message.slice(0, 240) }],
|
||||
};
|
||||
}
|
||||
|
||||
function withError<T extends unknown[]>(handler: (...args: T) => Promise<ReturnType<typeof result>>) {
|
||||
return async (...args: T) => {
|
||||
try {
|
||||
return await handler(...args);
|
||||
} catch (error) {
|
||||
return errorResult(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const pagination = {
|
||||
limit: z.number().int().min(1).max(200).optional().describe("返回数量,默认50,最大200"),
|
||||
offset: z.number().int().min(0).optional().describe("分页偏移量,默认0"),
|
||||
};
|
||||
|
||||
const resourceFilters = {
|
||||
query: z.string().max(100).optional().describe("账号名称或小红书号/抖音号,支持模糊搜索"),
|
||||
ip_location: z.string().max(100).optional().describe("IP地区关键词,支持模糊搜索"),
|
||||
cooperation_source: z.string().max(100).optional().describe("历史合作来源关键词,支持模糊搜索"),
|
||||
platform: z.string().max(30).optional().describe("平台,例如小红书;不传表示全部"),
|
||||
};
|
||||
|
||||
export function registerMcpOperationTools(server: McpServer, options: Options) {
|
||||
server.registerTool(
|
||||
"task_list",
|
||||
{
|
||||
title: "查询任务及进度",
|
||||
description: "查询 KOC 分发任务列表、领取数、发布数、第7天回收数及领取链接。",
|
||||
inputSchema: z.object({
|
||||
query: z.string().max(100).optional().describe("任务名或品牌/项目关键词"),
|
||||
status: z.string().max(30).optional().describe("任务状态;不传或 all 表示全部"),
|
||||
...pagination,
|
||||
}),
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
||||
},
|
||||
withError(async (input) => {
|
||||
const data = await taskList(input, options.bindings.KOC_PORTAL_URL?.trim() || "");
|
||||
return result(`共找到 ${data.total} 个任务。`, data);
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"task_get",
|
||||
{
|
||||
title: "查看任务完整情况",
|
||||
description: "查看指定任务、笔记、领取记录、发布回填和采集执行记录。",
|
||||
inputSchema: z.object({ task_id: z.string().min(1).describe("任务ID") }),
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
||||
},
|
||||
withError(async ({ task_id }) => {
|
||||
const data = await taskGet(task_id, options.bindings.KOC_PORTAL_URL?.trim() || "");
|
||||
return result(`已读取任务“${String(data.task.name)}”的完整情况。`, data);
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"recovery_list",
|
||||
{
|
||||
title: "查询数据回收队列",
|
||||
description: "查询已发布、未回填、待第7天数据或已到第7天仍未回填的笔记。",
|
||||
inputSchema: z.object({
|
||||
task_id: z.string().optional().describe("任务ID;不传则跨任务查询"),
|
||||
stage: z.enum(["all", "published", "unfilled", "waiting_day7", "day7_due"]).optional(),
|
||||
...pagination,
|
||||
}),
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
||||
},
|
||||
withError(async ({ task_id, stage, limit, offset }) => {
|
||||
const data = await recoveryList({ taskId: task_id, stage, limit, offset });
|
||||
return result(`数据回收队列共 ${data.total} 条记录。`, data);
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"recovery_export",
|
||||
{
|
||||
title: "导出任务完整数据",
|
||||
description: "生成任务完整 Excel,包含笔记原图、发布截图、创作者截图和回收数据。",
|
||||
inputSchema: z.object({ task_id: z.string().min(1).describe("任务ID") }),
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false },
|
||||
},
|
||||
withError(async ({ task_id }) => {
|
||||
const data = await recoveryExport(task_id, options.origin);
|
||||
return result(`导出文件已生成,下载链接将在 ${data.expires_at} 失效。`, data);
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"collection_plan_set",
|
||||
{
|
||||
title: "设置自动采集计划",
|
||||
description: "为任务设置开始日期及第1至第7天的自动采集日,系统在北京时间10:00执行。",
|
||||
inputSchema: z.object({
|
||||
task_id: z.string().min(1),
|
||||
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe("北京时间开始日期 YYYY-MM-DD"),
|
||||
days: z.array(z.number().int().min(1).max(7)).min(1).max(7).describe("需要采集的相对天数,例如 [2,5,7]"),
|
||||
}),
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
||||
},
|
||||
withError(async ({ task_id, start_date, days }) => {
|
||||
const data = await setCollectionPlan(task_id, start_date, days, options.bindings);
|
||||
return result(`已为任务设置 ${data.days.length} 个自动采集日。`, data);
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"collection_run_due",
|
||||
{
|
||||
title: "执行到期采集",
|
||||
description: "立即执行今天或此前已到期但尚未成功的自动采集任务。",
|
||||
inputSchema: z.object({ task_id: z.string().optional().describe("任务ID;不传则执行所有到期任务") }),
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
||||
},
|
||||
withError(async ({ task_id }) => {
|
||||
const data = await runDueCollections(task_id, options.bindings);
|
||||
return result(`到期采集完成:成功 ${data.succeeded},失败 ${data.failed}。`, data);
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"collection_collect_now",
|
||||
{
|
||||
title: "立即采集指定笔记",
|
||||
description: "对指定分发记录立即采集点赞、收藏和评论数据。",
|
||||
inputSchema: z.object({
|
||||
distribution_id: z.string().min(1).describe("分发记录ID,可从 task_get 或 recovery_list 获取"),
|
||||
schedule_day: z.number().int().min(1).max(7).optional().describe("标记为第几天采集,可不传"),
|
||||
}),
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
||||
},
|
||||
withError(async ({ distribution_id, schedule_day }) => {
|
||||
const data = await collectNow(distribution_id, schedule_day, options.bindings);
|
||||
return result("指定笔记采集完成。", data);
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"collection_retry_failed",
|
||||
{
|
||||
title: "补采异常数据",
|
||||
description: "重试指定任务或全部任务中采集状态异常的已发布笔记。",
|
||||
inputSchema: z.object({ task_id: z.string().optional().describe("任务ID;不传则补采最近异常任务") }),
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
||||
},
|
||||
withError(async ({ task_id }) => {
|
||||
const data = await retryFailed(task_id, options.bindings);
|
||||
return result(`补采完成:成功 ${data.succeeded},失败 ${data.failed}。`, data);
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"resource_search",
|
||||
{
|
||||
title: "搜索 KOC 账号资源",
|
||||
description: "按账号名称/账号号、IP地区、合作来源或平台搜索 KOC 资源。",
|
||||
inputSchema: z.object({ ...resourceFilters, ...pagination }),
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
||||
},
|
||||
withError(async ({ query, ip_location, cooperation_source, platform, limit, offset }) => {
|
||||
const data = await resourceSearch({ query, ipLocation: ip_location, cooperationSource: cooperation_source, platform, limit, offset });
|
||||
return result(`共找到 ${data.total} 个账号。`, data);
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"resource_get",
|
||||
{
|
||||
title: "查看 KOC 账号详情",
|
||||
description: "查看账号主页、账号号、粉丝数、IP地区以及全部合作记录。",
|
||||
inputSchema: z.object({ account_id: z.string().min(1).describe("KOC LOOP 账号ID") }),
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
||||
},
|
||||
withError(async ({ account_id }) => {
|
||||
const data = await resourceGet(account_id);
|
||||
return result(`已读取账号“${String(data.account.nickname)}”的详情。`, data);
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"resource_backfill_profile",
|
||||
{
|
||||
title: "补全公开账号信息",
|
||||
description: "根据已回填的发布链接补全账号主页、昵称、小红书号、IP地区和粉丝数。",
|
||||
inputSchema: z.object({
|
||||
distribution_id: z.string().optional().describe("分发记录ID,和发布链接二选一"),
|
||||
publish_url: z.string().optional().describe("小红书发布链接或包含链接的分享文案"),
|
||||
}).refine((value) => Boolean(value.distribution_id || value.publish_url), "请提供分发记录ID或发布链接"),
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
||||
},
|
||||
withError(async ({ distribution_id, publish_url }) => {
|
||||
const data = await backfillResourceProfile({ distributionId: distribution_id, publishUrl: publish_url }, options.bindings);
|
||||
return result(data.updated ? "账号公开信息已补全。" : "账号信息未发生变化。", data);
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"resource_export",
|
||||
{
|
||||
title: "导出 KOC 资源",
|
||||
description: "按账号、IP地区、合作来源或平台筛选并导出 KOC 资源 Excel。",
|
||||
inputSchema: z.object(resourceFilters),
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false },
|
||||
},
|
||||
withError(async ({ query, ip_location, cooperation_source, platform }) => {
|
||||
const data = await resourceExport({ query, ipLocation: ip_location, cooperationSource: cooperation_source, platform }, options.origin);
|
||||
return result(`已生成 ${data.account_count} 个账号的导出文件。`, data);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -165,6 +165,13 @@ export async function ensureSchema(database?: D1Database) {
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS mcp_export_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
];
|
||||
|
||||
for (const statement of statements) {
|
||||
@@ -340,6 +347,11 @@ export async function ensureSchema(database?: D1Database) {
|
||||
"CREATE INDEX IF NOT EXISTS auth_sessions_expires_at_idx ON auth_sessions(expires_at)",
|
||||
)
|
||||
.run();
|
||||
await db
|
||||
.prepare(
|
||||
"CREATE INDEX IF NOT EXISTS mcp_export_tokens_expires_at_idx ON mcp_export_tokens(expires_at)",
|
||||
)
|
||||
.run();
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE distributions
|
||||
|
||||
Reference in New Issue
Block a user