feat: 完善视频任务与 KOC 资源库
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
resolveXhsPublicAccountDetails,
|
||||
resolveXhsAccountProfileFromMcp,
|
||||
resolveXhsProfileDetailsFromMcp,
|
||||
resolveAccountProfileFromMcp,
|
||||
resolveProfileDetailsFromMcp,
|
||||
type CollectionMcpConfig,
|
||||
} from "./mcp-collection-client";
|
||||
import { hashText } from "./mvp-db";
|
||||
@@ -11,15 +11,20 @@ type DistributionAccountRow = {
|
||||
id: string;
|
||||
account_id: string | null;
|
||||
publish_url: string | null;
|
||||
platform: string;
|
||||
claimant_contact: string | null;
|
||||
};
|
||||
|
||||
type BackfillRow = DistributionAccountRow & {
|
||||
resolved_account_id: string | null;
|
||||
nickname: string | null;
|
||||
platform: string | null;
|
||||
platform_uid: string | null;
|
||||
public_account_id: string | null;
|
||||
profile_url: string | null;
|
||||
followers: number | null;
|
||||
gender: string | null;
|
||||
bio: string | null;
|
||||
tags: 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(
|
||||
db: DatabaseClient,
|
||||
distributionId: string,
|
||||
@@ -44,27 +66,42 @@ export async function enrichDistributionAccount(
|
||||
fallbackNickname: string,
|
||||
mcpConfig: CollectionMcpConfig,
|
||||
) {
|
||||
const profile = await resolveXhsAccountProfileFromMcp(
|
||||
publishUrl,
|
||||
fallbackNickname,
|
||||
mcpConfig,
|
||||
);
|
||||
const current = await db
|
||||
.prepare(
|
||||
`SELECT id, account_id, publish_url
|
||||
FROM distributions
|
||||
WHERE id = ?`,
|
||||
`SELECT d.id, d.account_id, d.publish_url, t.platform,
|
||||
cl.claimant_name AS claimant_contact
|
||||
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)
|
||||
.first<DistributionAccountRow>();
|
||||
if (!current || current.publish_url !== publishUrl) {
|
||||
return { updated: false, reason: "stale" as const };
|
||||
}
|
||||
const platform = current.platform === "抖音" ? "抖音" : "小红书";
|
||||
const profile = await resolveAccountProfileFromMcp(
|
||||
publishUrl,
|
||||
fallbackNickname,
|
||||
platform,
|
||||
mcpConfig,
|
||||
);
|
||||
|
||||
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
|
||||
.prepare(
|
||||
`UPDATE accounts SET
|
||||
@@ -82,6 +119,12 @@ export async function enrichDistributionAccount(
|
||||
WHEN ? IS NOT NULL AND (? > 0 OR followers = 0) THEN ?
|
||||
ELSE followers
|
||||
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
|
||||
WHERE id = ?`,
|
||||
)
|
||||
@@ -96,12 +139,19 @@ export async function enrichDistributionAccount(
|
||||
profile.followers,
|
||||
profile.followers,
|
||||
profile.followers,
|
||||
canonicalAccountId,
|
||||
profile.gender,
|
||||
profile.gender,
|
||||
profile.bio,
|
||||
profile.bio,
|
||||
currentContact,
|
||||
currentContact,
|
||||
targetAccountId,
|
||||
targetAccountId,
|
||||
)
|
||||
.run();
|
||||
return {
|
||||
updated: true,
|
||||
accountId: canonicalAccountId,
|
||||
accountId: targetAccountId,
|
||||
profileUrl: profile.profileUrl,
|
||||
};
|
||||
}
|
||||
@@ -111,8 +161,9 @@ export async function enrichDistributionAccount(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO accounts
|
||||
(id, platform, platform_uid, public_account_id, nickname, profile_url, ip_location, followers, post_count)
|
||||
VALUES (?, '小红书', ?, ?, ?, ?, ?, ?, 1)
|
||||
(id, platform, platform_uid, public_account_id, nickname, profile_url,
|
||||
ip_location, followers, gender, bio, current_contact, post_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(platform, platform_uid) DO UPDATE SET
|
||||
public_account_id = CASE
|
||||
WHEN excluded.public_account_id != ''
|
||||
@@ -131,17 +182,29 @@ export async function enrichDistributionAccount(
|
||||
THEN excluded.followers
|
||||
ELSE accounts.followers
|
||||
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`,
|
||||
)
|
||||
.bind(
|
||||
canonicalAccountId,
|
||||
targetAccountId,
|
||||
platform,
|
||||
profile.platformUid,
|
||||
profile.redId,
|
||||
profile.nickname || fallbackNickname,
|
||||
profile.profileUrl,
|
||||
profile.ipLocation,
|
||||
profile.followers ?? 0,
|
||||
profile.gender,
|
||||
profile.bio,
|
||||
currentContact,
|
||||
),
|
||||
db
|
||||
.prepare(
|
||||
@@ -150,10 +213,25 @@ export async function enrichDistributionAccount(
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
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
|
||||
.prepare(
|
||||
`DELETE FROM accounts
|
||||
@@ -165,14 +243,14 @@ export async function enrichDistributionAccount(
|
||||
)
|
||||
.bind(
|
||||
provisionalAccountId,
|
||||
canonicalAccountId,
|
||||
targetAccountId,
|
||||
provisionalAccountId,
|
||||
)
|
||||
.run();
|
||||
}
|
||||
return {
|
||||
updated: true,
|
||||
accountId: canonicalAccountId,
|
||||
accountId: targetAccountId,
|
||||
profileUrl: profile.profileUrl,
|
||||
};
|
||||
}
|
||||
@@ -188,15 +266,22 @@ export async function backfillAccountProfiles(
|
||||
d.id,
|
||||
d.account_id,
|
||||
d.publish_url,
|
||||
a.id AS resolved_account_id,
|
||||
a.nickname,
|
||||
a.platform,
|
||||
COALESCE(a.platform, t.platform) AS platform,
|
||||
a.platform_uid,
|
||||
a.public_account_id,
|
||||
a.profile_url,
|
||||
a.followers
|
||||
a.followers,
|
||||
a.gender,
|
||||
a.bio,
|
||||
a.tags,
|
||||
cl.claimant_name AS claimant_contact
|
||||
FROM distributions d
|
||||
JOIN tasks t ON t.id = d.task_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 != ''
|
||||
ORDER BY d.updated_at DESC
|
||||
LIMIT 100`,
|
||||
@@ -205,8 +290,34 @@ export async function backfillAccountProfiles(
|
||||
let attempted = 0;
|
||||
let updated = 0;
|
||||
let failed = 0;
|
||||
const backfilledAccounts = new Set<string>();
|
||||
|
||||
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;
|
||||
const noteId = (() => {
|
||||
try {
|
||||
@@ -227,19 +338,31 @@ export async function backfillAccountProfiles(
|
||||
row.platform === "小红书" &&
|
||||
!isDemoAccount &&
|
||||
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;
|
||||
attemptedThisRow = true;
|
||||
const details = await resolveXhsProfileDetailsFromMcp(
|
||||
const details = await resolveProfileDetailsFromMcp(
|
||||
row.profile_url ?? "",
|
||||
"小红书",
|
||||
mcpConfig,
|
||||
).catch(() =>
|
||||
resolveXhsPublicAccountDetails(row.profile_url ?? ""),
|
||||
);
|
||||
).catch(async () => ({
|
||||
...(await resolveXhsPublicAccountDetails(row.profile_url ?? "")),
|
||||
gender: "" as const,
|
||||
bio: "",
|
||||
recentNoteTitles: [] as string[],
|
||||
providerTags: [] as string[],
|
||||
}));
|
||||
if (
|
||||
row.account_id &&
|
||||
(details.redId || details.followers !== null)
|
||||
(details.redId ||
|
||||
details.followers !== null ||
|
||||
details.gender ||
|
||||
details.bio ||
|
||||
details.recentNoteTitles.length > 0)
|
||||
) {
|
||||
await db
|
||||
.prepare(
|
||||
@@ -256,6 +379,8 @@ export async function backfillAccountProfiles(
|
||||
WHEN ? != '' AND ? != '待识别' THEN ?
|
||||
ELSE ip_location
|
||||
END,
|
||||
gender = CASE WHEN ? != '' THEN ? ELSE gender END,
|
||||
bio = CASE WHEN ? != '' THEN ? ELSE bio END,
|
||||
last_seen_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
@@ -268,6 +393,10 @@ export async function backfillAccountProfiles(
|
||||
details.ipLocation ?? "",
|
||||
details.ipLocation ?? "",
|
||||
details.ipLocation ?? "",
|
||||
details.gender,
|
||||
details.gender,
|
||||
details.bio,
|
||||
details.bio,
|
||||
row.account_id,
|
||||
)
|
||||
.run();
|
||||
@@ -287,12 +416,17 @@ export async function backfillAccountProfiles(
|
||||
}
|
||||
const needsEnrichment =
|
||||
!isDemoAccount &&
|
||||
(!row.account_id ||
|
||||
(!row.resolved_account_id ||
|
||||
(row.platform === "小红书" &&
|
||||
!isVerifiedXhsProfileUrl(row.profile_url)) ||
|
||||
(row.platform === "抖音" &&
|
||||
!isVerifiedDouyinProfileUrl(row.profile_url)) ||
|
||||
(row.platform === "小红书" && !row.public_account_id) ||
|
||||
(row.platform === "抖音" && !row.public_account_id) ||
|
||||
(row.platform === "小红书" &&
|
||||
Number(row.followers ?? 0) === 0) ||
|
||||
(row.platform === "抖音" &&
|
||||
Number(row.followers ?? 0) === 0) ||
|
||||
row.platform_uid?.startsWith("pending-") ||
|
||||
Boolean(noteId && row.platform_uid === noteId));
|
||||
if (!needsEnrichment || !row.publish_url) {
|
||||
|
||||
Reference in New Issue
Block a user