Initial commit: KOC LOOP platform
This commit is contained in:
318
lib/account-enrichment-service.ts
Normal file
318
lib/account-enrichment-service.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import {
|
||||
resolveXhsPublicAccountDetails,
|
||||
resolveXhsAccountProfileFromMcp,
|
||||
resolveXhsProfileDetailsFromMcp,
|
||||
type CollectionMcpConfig,
|
||||
} from "./mcp-collection-client";
|
||||
import { hashText } from "./mvp-db";
|
||||
|
||||
type DistributionAccountRow = {
|
||||
id: string;
|
||||
account_id: string | null;
|
||||
publish_url: string | null;
|
||||
};
|
||||
|
||||
type BackfillRow = DistributionAccountRow & {
|
||||
nickname: string | null;
|
||||
platform: string | null;
|
||||
platform_uid: string | null;
|
||||
public_account_id: string | null;
|
||||
profile_url: string | null;
|
||||
followers: number | null;
|
||||
};
|
||||
|
||||
function isVerifiedXhsProfileUrl(value: string | null) {
|
||||
if (!value) return false;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
url.protocol === "https:" &&
|
||||
(url.hostname === "xiaohongshu.com" ||
|
||||
url.hostname.endsWith(".xiaohongshu.com")) &&
|
||||
url.pathname.startsWith("/user/profile/")
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function enrichDistributionAccount(
|
||||
db: D1Database,
|
||||
distributionId: string,
|
||||
publishUrl: string,
|
||||
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 = ?`,
|
||||
)
|
||||
.bind(distributionId)
|
||||
.first<DistributionAccountRow>();
|
||||
if (!current || current.publish_url !== publishUrl) {
|
||||
return { updated: false, reason: "stale" as const };
|
||||
}
|
||||
|
||||
const canonicalAccountId = `account-${hashText(
|
||||
`小红书:${profile.platformUid}`,
|
||||
)}`;
|
||||
if (current.account_id === canonicalAccountId) {
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE accounts SET
|
||||
nickname = ?,
|
||||
profile_url = ?,
|
||||
public_account_id = CASE
|
||||
WHEN ? != '' THEN ?
|
||||
ELSE public_account_id
|
||||
END,
|
||||
ip_location = CASE
|
||||
WHEN ? != '' AND ? != '待识别' THEN ?
|
||||
ELSE ip_location
|
||||
END,
|
||||
followers = CASE
|
||||
WHEN ? IS NOT NULL AND (? > 0 OR followers = 0) THEN ?
|
||||
ELSE followers
|
||||
END,
|
||||
last_seen_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(
|
||||
profile.nickname || fallbackNickname,
|
||||
profile.profileUrl,
|
||||
profile.redId,
|
||||
profile.redId,
|
||||
profile.ipLocation,
|
||||
profile.ipLocation,
|
||||
profile.ipLocation,
|
||||
profile.followers,
|
||||
profile.followers,
|
||||
profile.followers,
|
||||
canonicalAccountId,
|
||||
)
|
||||
.run();
|
||||
return {
|
||||
updated: true,
|
||||
accountId: canonicalAccountId,
|
||||
profileUrl: profile.profileUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const provisionalAccountId = current.account_id;
|
||||
await db.batch([
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO accounts
|
||||
(id, platform, platform_uid, public_account_id, nickname, profile_url, ip_location, followers, post_count)
|
||||
VALUES (?, '小红书', ?, ?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(platform, platform_uid) DO UPDATE SET
|
||||
public_account_id = CASE
|
||||
WHEN excluded.public_account_id != ''
|
||||
THEN excluded.public_account_id
|
||||
ELSE accounts.public_account_id
|
||||
END,
|
||||
nickname = excluded.nickname,
|
||||
profile_url = excluded.profile_url,
|
||||
ip_location = CASE
|
||||
WHEN excluded.ip_location != '' AND excluded.ip_location != '待识别'
|
||||
THEN excluded.ip_location
|
||||
ELSE accounts.ip_location
|
||||
END,
|
||||
followers = CASE
|
||||
WHEN excluded.followers > 0 OR accounts.followers = 0
|
||||
THEN excluded.followers
|
||||
ELSE accounts.followers
|
||||
END,
|
||||
post_count = accounts.post_count + 1,
|
||||
last_seen_at = CURRENT_TIMESTAMP`,
|
||||
)
|
||||
.bind(
|
||||
canonicalAccountId,
|
||||
profile.platformUid,
|
||||
profile.redId,
|
||||
profile.nickname || fallbackNickname,
|
||||
profile.profileUrl,
|
||||
profile.ipLocation,
|
||||
profile.followers ?? 0,
|
||||
),
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE distributions SET
|
||||
account_id = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND publish_url = ?`,
|
||||
)
|
||||
.bind(canonicalAccountId, distributionId, publishUrl),
|
||||
]);
|
||||
|
||||
if (provisionalAccountId) {
|
||||
await db
|
||||
.prepare(
|
||||
`DELETE FROM accounts
|
||||
WHERE id = ?
|
||||
AND id != ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM distributions WHERE account_id = ?
|
||||
)`,
|
||||
)
|
||||
.bind(
|
||||
provisionalAccountId,
|
||||
canonicalAccountId,
|
||||
provisionalAccountId,
|
||||
)
|
||||
.run();
|
||||
}
|
||||
return {
|
||||
updated: true,
|
||||
accountId: canonicalAccountId,
|
||||
profileUrl: profile.profileUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function backfillAccountProfiles(
|
||||
db: D1Database,
|
||||
mcpConfig: CollectionMcpConfig,
|
||||
limit = 10,
|
||||
) {
|
||||
const rows = await db
|
||||
.prepare(
|
||||
`SELECT
|
||||
d.id,
|
||||
d.account_id,
|
||||
d.publish_url,
|
||||
a.nickname,
|
||||
a.platform,
|
||||
a.platform_uid,
|
||||
a.public_account_id,
|
||||
a.profile_url,
|
||||
a.followers
|
||||
FROM distributions d
|
||||
LEFT JOIN accounts a ON a.id = d.account_id
|
||||
WHERE d.publish_url IS NOT NULL
|
||||
AND d.publish_url != ''
|
||||
ORDER BY d.updated_at DESC
|
||||
LIMIT 100`,
|
||||
)
|
||||
.all<BackfillRow>();
|
||||
let attempted = 0;
|
||||
let updated = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const row of rows.results) {
|
||||
if (attempted >= Math.max(1, Math.min(25, limit))) break;
|
||||
const noteId = (() => {
|
||||
try {
|
||||
const url = new URL(row.publish_url ?? "");
|
||||
return (
|
||||
url.pathname.match(
|
||||
/\/(?:discovery\/item|explore)\/([A-Za-z0-9_-]{8,80})/,
|
||||
)?.[1] ?? ""
|
||||
);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
const isDemoAccount = row.platform_uid?.startsWith("xhs-");
|
||||
let attemptedThisRow = false;
|
||||
let publicProfileUpdated = false;
|
||||
if (
|
||||
row.platform === "小红书" &&
|
||||
!isDemoAccount &&
|
||||
isVerifiedXhsProfileUrl(row.profile_url) &&
|
||||
(!row.public_account_id || Number(row.followers ?? 0) === 0)
|
||||
) {
|
||||
attempted += 1;
|
||||
attemptedThisRow = true;
|
||||
const details = await resolveXhsProfileDetailsFromMcp(
|
||||
row.profile_url ?? "",
|
||||
mcpConfig,
|
||||
).catch(() =>
|
||||
resolveXhsPublicAccountDetails(row.profile_url ?? ""),
|
||||
);
|
||||
if (
|
||||
row.account_id &&
|
||||
(details.redId || details.followers !== null)
|
||||
) {
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE accounts
|
||||
SET public_account_id = CASE
|
||||
WHEN ? != '' THEN ?
|
||||
ELSE public_account_id
|
||||
END,
|
||||
followers = CASE
|
||||
WHEN ? IS NOT NULL AND (? > 0 OR followers = 0) THEN ?
|
||||
ELSE followers
|
||||
END,
|
||||
ip_location = CASE
|
||||
WHEN ? != '' AND ? != '待识别' THEN ?
|
||||
ELSE ip_location
|
||||
END,
|
||||
last_seen_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(
|
||||
details.redId,
|
||||
details.redId,
|
||||
details.followers,
|
||||
details.followers,
|
||||
details.followers,
|
||||
details.ipLocation ?? "",
|
||||
details.ipLocation ?? "",
|
||||
details.ipLocation ?? "",
|
||||
row.account_id,
|
||||
)
|
||||
.run();
|
||||
publicProfileUpdated = true;
|
||||
}
|
||||
if (
|
||||
(details.redId || row.public_account_id) &&
|
||||
Number(details.followers ?? row.followers ?? 0) > 0
|
||||
) {
|
||||
updated += 1;
|
||||
continue;
|
||||
}
|
||||
if (attempted >= Math.max(1, Math.min(25, limit))) {
|
||||
if (publicProfileUpdated) updated += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const needsEnrichment =
|
||||
!isDemoAccount &&
|
||||
(!row.account_id ||
|
||||
(row.platform === "小红书" &&
|
||||
!isVerifiedXhsProfileUrl(row.profile_url)) ||
|
||||
(row.platform === "小红书" && !row.public_account_id) ||
|
||||
(row.platform === "小红书" &&
|
||||
Number(row.followers ?? 0) === 0) ||
|
||||
row.platform_uid?.startsWith("pending-") ||
|
||||
Boolean(noteId && row.platform_uid === noteId));
|
||||
if (!needsEnrichment || !row.publish_url) {
|
||||
if (publicProfileUpdated) updated += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!attemptedThisRow) attempted += 1;
|
||||
try {
|
||||
const result = await enrichDistributionAccount(
|
||||
db,
|
||||
row.id,
|
||||
row.publish_url,
|
||||
row.nickname || "待识别账号",
|
||||
mcpConfig,
|
||||
);
|
||||
if (result.updated || publicProfileUpdated) updated += 1;
|
||||
} catch {
|
||||
if (publicProfileUpdated) updated += 1;
|
||||
else failed += 1;
|
||||
}
|
||||
}
|
||||
return { attempted, updated, failed };
|
||||
}
|
||||
35
lib/admin-auth.ts
Normal file
35
lib/admin-auth.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
|
||||
export function isAdminEmail(input: string | null | undefined) {
|
||||
const allowedEmail = String(
|
||||
(env as unknown as { ADMIN_ALLOWED_EMAIL?: string }).ADMIN_ALLOWED_EMAIL ??
|
||||
"",
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const requestEmail = String(input ?? "").trim().toLowerCase();
|
||||
return Boolean(allowedEmail && requestEmail && requestEmail === allowedEmail);
|
||||
}
|
||||
|
||||
export function isAdminRequest(request: Request) {
|
||||
if (isAdminEmail(request.headers.get("oai-authenticated-user-email"))) {
|
||||
return true;
|
||||
}
|
||||
const expectedToken = String(
|
||||
(env as unknown as { ADMIN_INTERNAL_TOKEN?: string }).ADMIN_INTERNAL_TOKEN ??
|
||||
"",
|
||||
).trim();
|
||||
const requestToken = String(
|
||||
request.headers.get("x-koc-admin-token") ?? "",
|
||||
).trim();
|
||||
return Boolean(
|
||||
expectedToken &&
|
||||
requestToken &&
|
||||
expectedToken.length === requestToken.length &&
|
||||
expectedToken === requestToken,
|
||||
);
|
||||
}
|
||||
|
||||
export function adminForbidden() {
|
||||
return Response.json({ error: "无后台操作权限" }, { status: 403 });
|
||||
}
|
||||
471
lib/collection-service.ts
Normal file
471
lib/collection-service.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import {
|
||||
collectXhsMetricsFromMcp,
|
||||
type CollectionMcpConfig,
|
||||
} from "./mcp-collection-client";
|
||||
import { uid } from "./mvp-db";
|
||||
|
||||
type DistributionForCollection = {
|
||||
id: string;
|
||||
task_id: string;
|
||||
publish_url: string | null;
|
||||
ocr_status: string;
|
||||
};
|
||||
|
||||
type ScheduledTask = {
|
||||
id: string;
|
||||
collection_start_date: string;
|
||||
collection_days: string;
|
||||
};
|
||||
|
||||
type CollectionSource = "automatic" | "catchup" | "manual";
|
||||
|
||||
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) {
|
||||
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 shanghaiDateFromTimestamp(timestamp: number) {
|
||||
return new Date(timestamp + 8 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
function shanghaiHourFromTimestamp(timestamp: number) {
|
||||
return Number(
|
||||
new Date(timestamp + 8 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(11, 13),
|
||||
);
|
||||
}
|
||||
|
||||
function dueSchedules(
|
||||
startDate: string,
|
||||
days: number[],
|
||||
timestamp: number,
|
||||
) {
|
||||
const currentDate = shanghaiDateFromTimestamp(timestamp);
|
||||
const currentHour = shanghaiHourFromTimestamp(timestamp);
|
||||
return days
|
||||
.map((scheduleDay) => ({
|
||||
scheduleDay,
|
||||
scheduledDate: dateForScheduleDay(startDate, scheduleDay),
|
||||
}))
|
||||
.filter(
|
||||
(
|
||||
value,
|
||||
): value is { scheduleDay: number; scheduledDate: string } =>
|
||||
Boolean(
|
||||
value.scheduledDate &&
|
||||
(value.scheduledDate < currentDate ||
|
||||
(value.scheduledDate === currentDate && currentHour >= 10)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createCollectionRunTasks(
|
||||
db: D1Database,
|
||||
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: D1PreparedStatement[] = [];
|
||||
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}T10:00:00+08:00`,
|
||||
`等待第${scheduleDay}天 10: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: D1Database,
|
||||
distributionId: string,
|
||||
scheduledDate: string,
|
||||
scheduleDay: number | null,
|
||||
source: CollectionSource,
|
||||
mcpConfig: CollectionMcpConfig,
|
||||
) {
|
||||
const current = await db
|
||||
.prepare("SELECT * FROM distributions WHERE id = ?")
|
||||
.bind(distributionId)
|
||||
.first<DistributionForCollection>();
|
||||
if (!current) throw new Error("分发记录不存在");
|
||||
if (!current.publish_url) throw new Error("笔记尚未回填发布链接");
|
||||
|
||||
const scheduledAt = `${scheduledDate}T10: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("采集任务创建失败");
|
||||
if (run.status === "success") 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 } =
|
||||
await collectXhsMetricsFromMcp(current.publish_url, mcpConfig);
|
||||
const dayWeight = scheduleDay ?? 1;
|
||||
const successDescription =
|
||||
source === "automatic"
|
||||
? `成功 · 第${dayWeight}天 10:00自动采集`
|
||||
: source === "catchup"
|
||||
? `成功 · 第${dayWeight}天自动追采`
|
||||
: "成功 · 手动采集";
|
||||
|
||||
await db.batch([
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE collection_runs
|
||||
SET status = 'success',
|
||||
likes = ?,
|
||||
comments = ?,
|
||||
collects = ?,
|
||||
status_description = ?,
|
||||
completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(
|
||||
likes,
|
||||
comments,
|
||||
collects,
|
||||
successDescription,
|
||||
run.id,
|
||||
),
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE distributions
|
||||
SET latest_likes = ?,
|
||||
latest_comments = ?,
|
||||
latest_collects = ?,
|
||||
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,
|
||||
successDescription,
|
||||
scheduleDay,
|
||||
distributionId,
|
||||
),
|
||||
]);
|
||||
return { skipped: false, likes, comments, collects };
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "公开数据采集失败";
|
||||
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(
|
||||
`UPDATE distributions
|
||||
SET collection_status = 'failed',
|
||||
collection_status_description = ?,
|
||||
collection_updated_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.bind(message, distributionId),
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runScheduledCollections(
|
||||
db: D1Database,
|
||||
scheduledTimestamp: number,
|
||||
mcpConfig: CollectionMcpConfig,
|
||||
) {
|
||||
return runDueScheduledCollections(
|
||||
db,
|
||||
scheduledTimestamp,
|
||||
mcpConfig,
|
||||
"automatic",
|
||||
);
|
||||
}
|
||||
|
||||
export async function runDueScheduledCollections(
|
||||
db: D1Database,
|
||||
timestamp: number,
|
||||
mcpConfig: CollectionMcpConfig,
|
||||
source: Extract<CollectionSource, "automatic" | "catchup"> = "catchup",
|
||||
onlyTaskId?: string,
|
||||
) {
|
||||
const currentDate = shanghaiDateFromTimestamp(timestamp);
|
||||
const tasks = await db
|
||||
.prepare(
|
||||
`SELECT id, collection_start_date, collection_days
|
||||
FROM tasks
|
||||
WHERE collection_start_date IS NOT NULL
|
||||
AND collection_start_date != ''
|
||||
AND collection_days IS NOT NULL
|
||||
AND collection_days != '[]'
|
||||
AND (? IS NULL OR id = ?)`,
|
||||
)
|
||||
.bind(onlyTaskId ?? null, onlyTaskId ?? null)
|
||||
.all<ScheduledTask>();
|
||||
let dueTasks = 0;
|
||||
let attempted = 0;
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const task of tasks.results) {
|
||||
const schedules = dueSchedules(
|
||||
task.collection_start_date,
|
||||
parseDays(task.collection_days),
|
||||
timestamp,
|
||||
);
|
||||
for (const { scheduleDay, scheduledDate } of schedules) {
|
||||
dueTasks += 1;
|
||||
const distributions = await db
|
||||
.prepare(
|
||||
`SELECT d.id FROM distributions d
|
||||
WHERE d.task_id = ?
|
||||
AND d.publish_url IS NOT NULL
|
||||
AND d.publish_url != ''
|
||||
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(task.id, scheduledDate)
|
||||
.all<{ id: string }>();
|
||||
|
||||
for (const distribution of distributions.results) {
|
||||
attempted += 1;
|
||||
try {
|
||||
await collectDistributionMetrics(
|
||||
db,
|
||||
distribution.id,
|
||||
scheduledDate,
|
||||
scheduleDay,
|
||||
source,
|
||||
mcpConfig,
|
||||
);
|
||||
succeeded += 1;
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { currentDate, dueTasks, attempted, succeeded, failed };
|
||||
}
|
||||
|
||||
export async function retryFailedCollections(
|
||||
db: D1Database,
|
||||
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,
|
||||
};
|
||||
}
|
||||
26
lib/date-utils.ts
Normal file
26
lib/date-utils.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export function parseStoredDate(value: string) {
|
||||
const trimmed = value.trim();
|
||||
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(trimmed)
|
||||
? `${trimmed}T00:00:00+08:00`
|
||||
: /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(
|
||||
trimmed,
|
||||
)
|
||||
? `${trimmed.replace(" ", "T")}Z`
|
||||
: trimmed;
|
||||
return new Date(normalized);
|
||||
}
|
||||
|
||||
export function formatShanghaiDate(
|
||||
value: string | null | undefined,
|
||||
withTime = false,
|
||||
) {
|
||||
if (!value) return "—";
|
||||
const date = parseStoredDate(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
...(withTime ? { hour: "2-digit", minute: "2-digit" } : {}),
|
||||
}).format(date);
|
||||
}
|
||||
557
lib/feishu-client.ts
Normal file
557
lib/feishu-client.ts
Normal file
@@ -0,0 +1,557 @@
|
||||
const FEISHU_API_ORIGIN = "https://open.feishu.cn";
|
||||
const MAX_SHEET_ROWS = 5_000;
|
||||
const MAX_SHEET_COLUMNS = 100;
|
||||
const MAX_CONTENT_ROWS = 1_000;
|
||||
const MAX_MEDIA_BYTES = 20_000_000;
|
||||
|
||||
export type FeishuBindings = {
|
||||
FEISHU_APP_ID?: string;
|
||||
FEISHU_APP_SECRET?: string;
|
||||
};
|
||||
|
||||
export type FeishuSourceImage = {
|
||||
index: number;
|
||||
fileToken: string;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
};
|
||||
|
||||
export type FeishuSourceRow = {
|
||||
sourceRow: number;
|
||||
title: string;
|
||||
body: string;
|
||||
images: FeishuSourceImage[];
|
||||
};
|
||||
|
||||
export type FeishuSource = {
|
||||
url: string;
|
||||
wikiToken: string;
|
||||
spreadsheetToken: string;
|
||||
sheetId: string;
|
||||
sheetName: string;
|
||||
syncedAt: string;
|
||||
columns: string[];
|
||||
rows: FeishuSourceRow[];
|
||||
};
|
||||
|
||||
type FetchLike = typeof fetch;
|
||||
|
||||
type FeishuEnvelope<T> = {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: T;
|
||||
tenant_access_token?: string;
|
||||
expire?: number;
|
||||
};
|
||||
|
||||
type SheetInfo = {
|
||||
sheet_id?: string;
|
||||
title?: string;
|
||||
hidden?: boolean;
|
||||
resource_type?: string;
|
||||
grid_properties?: {
|
||||
row_count?: number;
|
||||
column_count?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type CachedAccessToken = {
|
||||
appId: string;
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
let cachedAccessToken: CachedAccessToken | null = null;
|
||||
|
||||
export class FeishuSourceError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status = 500) {
|
||||
super(message);
|
||||
this.name = "FeishuSourceError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function bindingValue(value: unknown) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function providerMessage(value: unknown) {
|
||||
const message = String(value ?? "").trim();
|
||||
return message
|
||||
.replace(/https?:\/\/[^\s"'<>]+/gi, "[飞书权限链接]")
|
||||
.slice(0, 240);
|
||||
}
|
||||
|
||||
function normalizeHeader(value: unknown) {
|
||||
return cellText(value)
|
||||
.toLowerCase()
|
||||
.replace(/[\s()()【】[\]_\-—::/\\]+/g, "");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cellText(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(cellText)
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
}
|
||||
if (!isRecord(value) || value.type === "embed-image") return "";
|
||||
if (typeof value.text === "string") return value.text.trim();
|
||||
if (typeof value.value === "string") return value.value.trim();
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractImages(value: unknown, output: FeishuSourceImage[]) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) extractImages(item, output);
|
||||
return;
|
||||
}
|
||||
if (!isRecord(value)) return;
|
||||
const fileToken = bindingValue(value.fileToken ?? value.image_token);
|
||||
if (value.type === "embed-image" && fileToken) {
|
||||
output.push({
|
||||
index: 0,
|
||||
fileToken,
|
||||
width:
|
||||
typeof value.width === "number"
|
||||
? value.width
|
||||
: typeof value.image_width === "number"
|
||||
? value.image_width
|
||||
: null,
|
||||
height:
|
||||
typeof value.height === "number"
|
||||
? value.height
|
||||
: typeof value.image_height === "number"
|
||||
? value.image_height
|
||||
: null,
|
||||
});
|
||||
}
|
||||
for (const child of Object.values(value)) {
|
||||
if (child !== value.fileToken && child !== value.image_token) {
|
||||
extractImages(child, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function columnName(index: number) {
|
||||
let value = index + 1;
|
||||
let result = "";
|
||||
while (value > 0) {
|
||||
const remainder = (value - 1) % 26;
|
||||
result = String.fromCharCode(65 + remainder) + result;
|
||||
value = Math.floor((value - 1) / 26);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function headerMatches(header: string, candidates: RegExp[]) {
|
||||
return candidates.some((candidate) => candidate.test(header));
|
||||
}
|
||||
|
||||
function findHeader(values: unknown[][]) {
|
||||
let best:
|
||||
| {
|
||||
rowIndex: number;
|
||||
idIndex: number;
|
||||
titleIndex: number;
|
||||
bodyIndex: number;
|
||||
tagsIndex: number;
|
||||
score: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
for (let rowIndex = 0; rowIndex < Math.min(values.length, 20); rowIndex += 1) {
|
||||
const headers = (values[rowIndex] ?? []).map(normalizeHeader);
|
||||
const idIndex = headers.findIndex((header) =>
|
||||
headerMatches(header, [/^id$/, /作品id/, /序号/, /编号/]),
|
||||
);
|
||||
const titleIndex = headers.findIndex((header) =>
|
||||
headerMatches(header, [/标题/, /题目/]),
|
||||
);
|
||||
const bodyIndex = headers.findIndex((header) =>
|
||||
headerMatches(header, [/笔记内容/, /正文/, /文案/, /^内容$/]),
|
||||
);
|
||||
const tagsIndex = headers.findIndex((header) =>
|
||||
headerMatches(header, [/标签/, /话题/, /^tags?$/]),
|
||||
);
|
||||
const score =
|
||||
(titleIndex >= 0 ? 5 : 0) +
|
||||
(bodyIndex >= 0 ? 5 : 0) +
|
||||
(idIndex >= 0 ? 1 : 0) +
|
||||
(tagsIndex >= 0 ? 1 : 0);
|
||||
if (!best || score > best.score) {
|
||||
best = {
|
||||
rowIndex,
|
||||
idIndex,
|
||||
titleIndex,
|
||||
bodyIndex,
|
||||
tagsIndex,
|
||||
score,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!best || best.titleIndex < 0 || best.bodyIndex < 0) {
|
||||
throw new FeishuSourceError(
|
||||
"没有找到“标题”和“正文/笔记内容”列,请检查飞书表头",
|
||||
422,
|
||||
);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function parseRows(values: unknown[][]) {
|
||||
const header = findHeader(values);
|
||||
const headerRow = values[header.rowIndex] ?? [];
|
||||
const usedSourceRows = new Set<number>();
|
||||
const rows: FeishuSourceRow[] = [];
|
||||
let maxImageCount = 0;
|
||||
|
||||
for (
|
||||
let rowIndex = header.rowIndex + 1;
|
||||
rowIndex < values.length && rows.length < MAX_CONTENT_ROWS;
|
||||
rowIndex += 1
|
||||
) {
|
||||
const row = values[rowIndex] ?? [];
|
||||
const title = cellText(row[header.titleIndex]);
|
||||
if (!title) continue;
|
||||
const rawBody = cellText(row[header.bodyIndex]);
|
||||
const tags =
|
||||
header.tagsIndex >= 0 ? cellText(row[header.tagsIndex]) : "";
|
||||
const body =
|
||||
tags && !rawBody.includes(tags)
|
||||
? [rawBody, tags].filter(Boolean).join("\n\n")
|
||||
: rawBody;
|
||||
const idValue =
|
||||
header.idIndex >= 0
|
||||
? Number.parseInt(cellText(row[header.idIndex]), 10)
|
||||
: Number.NaN;
|
||||
let sourceRow =
|
||||
Number.isInteger(idValue) && idValue > 0 ? idValue : rowIndex + 1;
|
||||
if (usedSourceRows.has(sourceRow)) sourceRow = rowIndex + 1;
|
||||
usedSourceRows.add(sourceRow);
|
||||
|
||||
const collectedImages: FeishuSourceImage[] = [];
|
||||
for (const cell of row) extractImages(cell, collectedImages);
|
||||
const seenTokens = new Set<string>();
|
||||
const images = collectedImages
|
||||
.filter((image) => {
|
||||
if (seenTokens.has(image.fileToken)) return false;
|
||||
seenTokens.add(image.fileToken);
|
||||
return true;
|
||||
})
|
||||
.map((image, imageIndex) => ({ ...image, index: imageIndex + 1 }));
|
||||
maxImageCount = Math.max(maxImageCount, images.length);
|
||||
rows.push({ sourceRow, title, body, images });
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
throw new FeishuSourceError("表格中没有可导入的有效标题行", 422);
|
||||
}
|
||||
|
||||
const matchedColumns = [
|
||||
header.idIndex >= 0 ? cellText(headerRow[header.idIndex]) : "",
|
||||
cellText(headerRow[header.titleIndex]) || "标题",
|
||||
header.tagsIndex >= 0 ? cellText(headerRow[header.tagsIndex]) : "",
|
||||
cellText(headerRow[header.bodyIndex]) || "正文",
|
||||
...Array.from({ length: maxImageCount }, (_, index) => `图片${index + 1}`),
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
columns: [...new Set(matchedColumns)],
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
async function jsonEnvelope<T>(
|
||||
response: Response,
|
||||
fallbackMessage: string,
|
||||
): Promise<FeishuEnvelope<T>> {
|
||||
const text = await response.text();
|
||||
let payload: FeishuEnvelope<T>;
|
||||
try {
|
||||
payload = JSON.parse(text) as FeishuEnvelope<T>;
|
||||
} catch {
|
||||
throw new FeishuSourceError(
|
||||
`${fallbackMessage}(飞书返回了异常响应)`,
|
||||
502,
|
||||
);
|
||||
}
|
||||
if (!response.ok || (typeof payload.code === "number" && payload.code !== 0)) {
|
||||
const code = Number(payload.code);
|
||||
const rawMessage = providerMessage(payload.msg);
|
||||
if ([99991672, 99991679].includes(code)) {
|
||||
throw new FeishuSourceError(
|
||||
"飞书应用缺少电子表格或知识库读取权限,请先在飞书开放平台开通权限",
|
||||
403,
|
||||
);
|
||||
}
|
||||
if ([131006, 1310213].includes(code) || response.status === 403) {
|
||||
throw new FeishuSourceError(
|
||||
"飞书应用没有这张表的访问权限,请在表格中添加该文档应用或将应用加入知识库",
|
||||
403,
|
||||
);
|
||||
}
|
||||
if ([131005, 1310214].includes(code) || response.status === 404) {
|
||||
throw new FeishuSourceError("没有找到对应的飞书表格", 404);
|
||||
}
|
||||
throw new FeishuSourceError(
|
||||
rawMessage
|
||||
? `${fallbackMessage}:${rawMessage}`
|
||||
: `${fallbackMessage}(HTTP ${response.status})`,
|
||||
response.status >= 400 ? response.status : 502,
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function feishuConfig(bindings: FeishuBindings) {
|
||||
const appId = bindingValue(bindings.FEISHU_APP_ID);
|
||||
const appSecret = bindingValue(bindings.FEISHU_APP_SECRET);
|
||||
if (!appId || !appSecret) {
|
||||
throw new FeishuSourceError(
|
||||
"飞书 API 尚未配置,请先配置应用的 App ID 和 App Secret",
|
||||
503,
|
||||
);
|
||||
}
|
||||
return { appId, appSecret };
|
||||
}
|
||||
|
||||
async function accessToken(
|
||||
bindings: FeishuBindings,
|
||||
fetchImpl: FetchLike,
|
||||
) {
|
||||
const { appId, appSecret } = feishuConfig(bindings);
|
||||
if (
|
||||
cachedAccessToken?.appId === appId &&
|
||||
cachedAccessToken.expiresAt > Date.now() + 60_000
|
||||
) {
|
||||
return cachedAccessToken.token;
|
||||
}
|
||||
const response = await fetchImpl(
|
||||
`${FEISHU_API_ORIGIN}/open-apis/auth/v3/tenant_access_token/internal`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
||||
signal: AbortSignal.timeout(12_000),
|
||||
},
|
||||
);
|
||||
const payload = await jsonEnvelope<never>(response, "获取飞书访问凭证失败");
|
||||
const token = bindingValue(payload.tenant_access_token);
|
||||
if (!token) {
|
||||
throw new FeishuSourceError("飞书没有返回有效访问凭证", 502);
|
||||
}
|
||||
cachedAccessToken = {
|
||||
appId,
|
||||
token,
|
||||
expiresAt: Date.now() + Math.max(300, Number(payload.expire) || 7_200) * 1_000,
|
||||
};
|
||||
return token;
|
||||
}
|
||||
|
||||
async function feishuGet<T>(
|
||||
path: string,
|
||||
token: string,
|
||||
fetchImpl: FetchLike,
|
||||
fallbackMessage: string,
|
||||
params?: URLSearchParams,
|
||||
) {
|
||||
const url = new URL(path, FEISHU_API_ORIGIN);
|
||||
if (params) url.search = params.toString();
|
||||
const response = await fetchImpl(url.toString(), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
const payload = await jsonEnvelope<T>(response, fallbackMessage);
|
||||
if (!payload.data) {
|
||||
throw new FeishuSourceError(`${fallbackMessage}(响应缺少数据)`, 502);
|
||||
}
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
function parseSourceUrl(input: string) {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(input);
|
||||
} catch {
|
||||
throw new FeishuSourceError("飞书链接格式无效", 400);
|
||||
}
|
||||
const validHost =
|
||||
url.hostname === "feishu.cn" ||
|
||||
url.hostname.endsWith(".feishu.cn") ||
|
||||
url.hostname === "larksuite.com" ||
|
||||
url.hostname.endsWith(".larksuite.com");
|
||||
if (!["http:", "https:"].includes(url.protocol) || !validHost) {
|
||||
throw new FeishuSourceError("请粘贴飞书 Wiki 或电子表格链接", 400);
|
||||
}
|
||||
const match = url.pathname.match(/\/(wiki|sheets|spreadsheets)\/([^/?]+)/);
|
||||
if (!match) {
|
||||
throw new FeishuSourceError("请粘贴飞书 Wiki 或电子表格链接", 400);
|
||||
}
|
||||
return {
|
||||
url,
|
||||
kind: match[1],
|
||||
token: match[2],
|
||||
requestedSheetId: bindingValue(url.searchParams.get("sheet")),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readFeishuSource(
|
||||
input: string,
|
||||
bindings: FeishuBindings,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
): Promise<FeishuSource> {
|
||||
const parsed = parseSourceUrl(input);
|
||||
const token = await accessToken(bindings, fetchImpl);
|
||||
let wikiToken = "";
|
||||
let spreadsheetToken = parsed.token;
|
||||
|
||||
if (parsed.kind === "wiki") {
|
||||
wikiToken = parsed.token;
|
||||
const nodeData = await feishuGet<{
|
||||
node?: { obj_type?: string; obj_token?: string };
|
||||
}>(
|
||||
"/open-apis/wiki/v2/spaces/get_node",
|
||||
token,
|
||||
fetchImpl,
|
||||
"读取飞书知识库节点失败",
|
||||
new URLSearchParams({ token: parsed.token }),
|
||||
);
|
||||
if (nodeData.node?.obj_type !== "sheet" || !nodeData.node.obj_token) {
|
||||
throw new FeishuSourceError("该飞书 Wiki 链接不是电子表格", 422);
|
||||
}
|
||||
spreadsheetToken = nodeData.node.obj_token;
|
||||
}
|
||||
|
||||
const sheetData = await feishuGet<{ sheets?: SheetInfo[] }>(
|
||||
`/open-apis/sheets/v3/spreadsheets/${encodeURIComponent(spreadsheetToken)}/sheets/query`,
|
||||
token,
|
||||
fetchImpl,
|
||||
"读取飞书工作表列表失败",
|
||||
);
|
||||
const visibleSheets = (sheetData.sheets ?? []).filter(
|
||||
(sheet) =>
|
||||
sheet.sheet_id &&
|
||||
sheet.resource_type !== "bitable" &&
|
||||
sheet.hidden !== true,
|
||||
);
|
||||
let selectedSheet = parsed.requestedSheetId
|
||||
? visibleSheets.find((sheet) => sheet.sheet_id === parsed.requestedSheetId)
|
||||
: undefined;
|
||||
if (!parsed.requestedSheetId) {
|
||||
if (visibleSheets.length === 1) {
|
||||
selectedSheet = visibleSheets[0];
|
||||
} else if (visibleSheets.length > 1) {
|
||||
throw new FeishuSourceError(
|
||||
"该表格包含多个工作表,请打开目标工作表后复制带 sheet 参数的链接",
|
||||
422,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!selectedSheet?.sheet_id) {
|
||||
throw new FeishuSourceError("链接中的工作表不存在或已隐藏", 404);
|
||||
}
|
||||
|
||||
const rowCount = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
MAX_SHEET_ROWS,
|
||||
Number(selectedSheet.grid_properties?.row_count) || 200,
|
||||
),
|
||||
);
|
||||
const columnCount = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
MAX_SHEET_COLUMNS,
|
||||
Number(selectedSheet.grid_properties?.column_count) || 26,
|
||||
),
|
||||
);
|
||||
const range = `${selectedSheet.sheet_id}!A1:${columnName(columnCount - 1)}${rowCount}`;
|
||||
const valuesParams = new URLSearchParams();
|
||||
valuesParams.append("ranges", range);
|
||||
const valuesData = await feishuGet<{
|
||||
valueRanges?: Array<{ values?: unknown[][] }>;
|
||||
}>(
|
||||
`/open-apis/sheets/v2/spreadsheets/${encodeURIComponent(spreadsheetToken)}/values_batch_get`,
|
||||
token,
|
||||
fetchImpl,
|
||||
"读取飞书表格内容失败",
|
||||
valuesParams,
|
||||
);
|
||||
const values = valuesData.valueRanges?.[0]?.values;
|
||||
if (!Array.isArray(values)) {
|
||||
throw new FeishuSourceError("飞书表格没有返回可读取的单元格", 422);
|
||||
}
|
||||
const parsedRows = parseRows(values);
|
||||
|
||||
return {
|
||||
url: parsed.url.toString(),
|
||||
wikiToken,
|
||||
spreadsheetToken,
|
||||
sheetId: selectedSheet.sheet_id,
|
||||
sheetName: bindingValue(selectedSheet.title) || "未命名工作表",
|
||||
syncedAt: new Date().toISOString(),
|
||||
columns: parsedRows.columns,
|
||||
rows: parsedRows.rows,
|
||||
};
|
||||
}
|
||||
|
||||
export async function downloadFeishuMedia(
|
||||
fileToken: string,
|
||||
bindings: FeishuBindings,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
) {
|
||||
const normalizedToken = bindingValue(fileToken);
|
||||
if (!/^[A-Za-z0-9_-]{8,240}$/.test(normalizedToken)) {
|
||||
throw new FeishuSourceError("飞书图片标识无效", 400);
|
||||
}
|
||||
const token = await accessToken(bindings, fetchImpl);
|
||||
const response = await fetchImpl(
|
||||
`${FEISHU_API_ORIGIN}/open-apis/drive/v1/medias/${encodeURIComponent(normalizedToken)}/download`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
},
|
||||
);
|
||||
const declaredSize = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredSize) && declaredSize > MAX_MEDIA_BYTES) {
|
||||
throw new FeishuSourceError("飞书图片超过20MB,无法同步", 413);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new FeishuSourceError(
|
||||
response.status === 403
|
||||
? "飞书应用没有这张图片的下载权限"
|
||||
: `下载飞书图片失败(HTTP ${response.status})`,
|
||||
response.status === 403 ? 403 : 502,
|
||||
);
|
||||
}
|
||||
const bytes = await response.arrayBuffer();
|
||||
if (bytes.byteLength > MAX_MEDIA_BYTES) {
|
||||
throw new FeishuSourceError("飞书图片超过20MB,无法同步", 413);
|
||||
}
|
||||
return {
|
||||
bytes,
|
||||
contentType:
|
||||
response.headers.get("content-type")?.split(";")[0] ||
|
||||
"application/octet-stream",
|
||||
};
|
||||
}
|
||||
|
||||
export function clearFeishuAccessTokenCacheForTests() {
|
||||
cachedAccessToken = null;
|
||||
}
|
||||
828
lib/feishu-source-snapshot.json
Normal file
828
lib/feishu-source-snapshot.json
Normal file
@@ -0,0 +1,828 @@
|
||||
{
|
||||
"wikiToken": "BSzxwRbGJi5dWoksauicUjtonks",
|
||||
"sheetId": "954953",
|
||||
"sheetName": "打包骑手500篇",
|
||||
"sourceRange": "B1:E508",
|
||||
"syncedAt": "2026-07-27T11:30:38.566Z",
|
||||
"columns": [
|
||||
"序号(不能改)",
|
||||
"标题",
|
||||
"笔记内容(正文+话题)",
|
||||
"图片1",
|
||||
"图片2",
|
||||
"图片3"
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"sourceRow": 1,
|
||||
"title": "🚗宝妈开窍了!我的“接娃神车”有了新使命✨",
|
||||
"body": "全职带娃第三年,每天除了接送娃就是在小区里瞎转悠😭\n刷到业主载骑手的帖子,突然灵光一闪!我这台“接娃神车”后座闲着也是闲着,不如发挥点余热~\n今天下午在小区门口蹲了十分钟,嘿!真“打包”到一个送鲜花的某团骑手小哥💐\n他连声说谢谢,我心里暖暖的❤️ 这可是我三年来第一次觉得,原来我这小区,也需要我!\n这种被需要的感觉,比带娃还治愈😭\n\n#宝妈兼职 #小区互助 #打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/1/1",
|
||||
"width": 1242,
|
||||
"height": 1656
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"key": "content-assets/954953/1/2",
|
||||
"width": 1080,
|
||||
"height": 1501
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 2,
|
||||
"title": "🗺️小区“活地图”上线!骑手都看呆了🤩",
|
||||
"body": "以前是导游,现在是小区专属“活地图”!谁懂啊家人们🤣\n哪栋楼在哪个岔口拐弯,哪个减速带最颠,我闭着眼都能开到,比导航还准!\n今天顺路带了个某团骑手小哥,从小区门口到目的地,三分钟直达!\n他下车时惊到失语:“姐,你对这小区也太熟了吧!”\n哈哈,那是~ 姐的青春都献给这几条路啦,能帮到他真的很开心🥳\n\n#小区生活 #顺路带一程 #美团骑手 #打包骑手 #骑手#外卖小哥 #当了一把骑手的骑手",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/2/1",
|
||||
"width": 1242,
|
||||
"height": 1656
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"key": "content-assets/954953/2/2",
|
||||
"width": 960,
|
||||
"height": 1280
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 3,
|
||||
"title": "💖全职妈妈的顺风车,暖到某团骑手心坎里❤️",
|
||||
"body": "全职妈妈的生活,好像只有娃和家,偶尔也会觉得有点枯燥😮💨\n今天送完娃回家,在小区门口看到一个某团骑手小哥,电动车停在路边,对着手机地图一脸愁容\n我摇下车窗问他是不是迷路了,他说第一次来,导航绕得头晕😵💫\n一看他要去的楼栋,正好在我家附近!“上来吧,我带你过去~”\n小哥有点不好意思,但还是坐了上来,送到后连声道谢,说省了好多时间\n原来,能用自己的小车帮到别人,比刷一下午剧有意义多了🥰\n\n#全职妈妈 #暖心瞬间 #当了一把骑手的骑手#打包骑手 #骑手 #美团骑手 #外卖小哥",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/3/1",
|
||||
"width": 1242,
|
||||
"height": 1656
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 4,
|
||||
"title": "全职宝妈终于不再是只会带娃的透明人啦!",
|
||||
"body": "全职带娃3年,每天围着娃转日子琐碎又单调👶,总觉得自己没什么价值。\n今天接娃放学,看见外卖小哥在小区门口急着找楼栋刚好顺路,就载了他一程。小哥一个劲说谢谢,娃还夸我厉害❤️ 。\n原来举手之劳就能帮到别人,以后碰到迷路的小哥,我都愿意打包你们一程~ \n\n#打包骑手 #全职妈妈 #暖心瞬间 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/4/1",
|
||||
"width": 792,
|
||||
"height": 1280
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"key": "content-assets/954953/4/2",
|
||||
"width": 951,
|
||||
"height": 1280
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 5,
|
||||
"title": "🎒等娃间隙,宝妈顺手打包骑手小哥🚗",
|
||||
"body": "每天下午校门口等娃🏫,无聊刷手机。今天看到一个骑手小哥在路边打电话,看样子是小区里哪栋楼又不好找了。我这 “老司机” 一看就知道他要去哪儿!“小哥,去 12栋吗?我顺路带你!” 他惊喜地跳上车,送到后连声感谢。等娃的空档期,还能顺手帮个忙,感觉时间都变得有意义了!😊# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈日常 #校门口 #等娃碎片",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/5/1",
|
||||
"width": 1242,
|
||||
"height": 1656
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 6,
|
||||
"title": "跟老公炫耀我的“新工作”,他惊了 😲",
|
||||
"body": "晚饭时我神神秘秘地跟老公说:“我找到一份新工作了,时间自由,还能带着娃一起干。”他惊得筷子都掉了,问我到底干啥 🥢。我笑着说:“当本小区的野生导航员,专门拯救迷路的外卖小哥!” 😂 他听完哭笑不得,但还是给我竖了个大拇指。这工作虽然没工资,但情绪价值拉满!我愿意打包,做个快乐的“兼职妈妈”,让生活多一点乐趣 🎈。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/6/1",
|
||||
"width": 800,
|
||||
"height": 1420
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 7,
|
||||
"title": "🥳被骑手夸活菩萨!宝妈开心到开花🌸",
|
||||
"body": "今天在小区门口,又 “打包” 了一个骑手小哥。他要去的那栋楼,正好是我家楼下。送到后,他感激地说:“姐,你真是活菩萨!” 哈哈,被夸得我心花怒放!其实就是举手之劳,但能帮到他们,看到他们轻松一点,我就觉得特别开心。全职妈妈的生活,也能找到自己的价值感!💪# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈生活 #价值感 #暖心日常",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/7/1",
|
||||
"width": 1242,
|
||||
"height": 1656
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 8,
|
||||
"title": "终于找到了一份不耽误接娃的活儿 🎒",
|
||||
"body": "每次看到那些招聘广告写着“朝九晚五”,我就只能默默叹气。全职妈妈的时间太碎了 🧩。直到我在网上看到了#打包骑手#这个话题,我恍然大悟:这不就是为我量身定制的“兼职”吗!每天接送娃的必经之路上,顺手带个路、刷个门禁,完全不耽误事儿 ⏱️。我愿意打包,用我这碎片化的时间,拼凑出一点对社会的贡献 🧩。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/8/1",
|
||||
"width": 313,
|
||||
"height": 557
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 9,
|
||||
"title": "我闺女今天问我:妈妈你是超人吗 🦸♀️",
|
||||
"body": "今天接闺女放学,路过门岗,我熟练地跟保安打了个招呼,把一个被拦住的骑手带了进来。闺女仰着头问我:“妈妈,你是专门救人的超人吗?” 🥺 听到这句话,我心都化了。全职妈妈在孩子眼里,原来也可以是个闪闪发光的超人 ✨。我愿意打包,为了成为孩子眼里的榜样,把这件充满正能量的“兼职”一直做下去 💪。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/9/1",
|
||||
"width": 3576,
|
||||
"height": 6356
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 10,
|
||||
"title": "我的小电驴,今天迎来了新乘客 🛵",
|
||||
"body": "买完菜回来,看到一个骑手在小区门口🪫。我二话不说,让他坐上的小电驴后座,一路狂飙把他送到了楼下 💨。平时这后座只载我家那个小神兽,今天迎来了新乘客,感觉还挺奇妙的。原来我的小电驴还能发挥这么大作用!我愿意打包,让我的小电驴成为小区里的“爱心专车” 🚗。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/10/1",
|
||||
"width": 960,
|
||||
"height": 1280
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 11,
|
||||
"title": "在宝妈群里发了条消息,瞬间被点赞 💬",
|
||||
"body": "刚才在小区的宝妈群里发了条消息:“以后大家遇到找不着路的骑手,就顺手指个路吧,就当给娃积德了。” 没想到瞬间收到了几十个点赞 👍。大家纷纷表示这主意好,不用花钱不用费时,还能教孩子助人为乐。原来全职妈妈们的能量这么大!我愿意打包,和宝妈们一起,把我们小区变成最有爱的地方 🌸。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/11/1",
|
||||
"width": 546,
|
||||
"height": 940
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 12,
|
||||
"title": "今天收到了一份特殊的“工资” 🎁",
|
||||
"body": "我这“兼职向导”当了一个多星期了,今天帮一个骑手指完路,他突然从口袋里掏出一颗糖递给我家娃:“谢谢阿姨,也谢谢小朋友。” 🍬 看着娃开心的笑脸,我觉得这颗糖比任何工资都珍贵。全职妈妈的价值,不一定非要用金钱来衡量。我愿意打包,为了这份纯粹的感激,继续我的“带路事业” 🌟。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/12/1",
|
||||
"width": 313,
|
||||
"height": 557
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 13,
|
||||
"title": "💖骑手一句谢谢姐!宝妈暖心一整天✨",
|
||||
"body": "今天在小区门口,又 “打包” 了一个骑手小哥。他要去的那栋楼,正好是我家楼下。送到后,他感激地对我说了声 “谢谢姐!” 就这么简单的一句话,却是我最好的 “勋章”。全职妈妈的生活,有时候会觉得有点枯燥,但这些小小的善意和被需要的感觉,真的能给我带来很多快乐和动力。🥰# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈心情 #暖心瞬间 #宝妈价值",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/13/1",
|
||||
"width": 1242,
|
||||
"height": 1656
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 14,
|
||||
"title": "被婆婆吐槽每天瞎忙,我却乐在其中 👵",
|
||||
"body": "我婆婆最近总吐槽我,说我每天接个娃还要在小区里到处转悠,瞎忙活 🙄。她不知道,我这是在忙着我的“新事业”呢!帮骑手指路、按电梯、开单元门,这些事虽然微不足道,但让我觉得每天都很充实 🏃♀️。我愿意打包,不管别人怎么说,做自己觉得有意义的事,就是最大的快乐 😊。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/14/1",
|
||||
"width": 400,
|
||||
"height": 527
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 15,
|
||||
"title": "这大概是我做过,门槛最低的兼职了 🚪",
|
||||
"body": "不用投简历,不用面试,只要有一颗热心肠,随时都能上岗!这大概是我这辈子做过门槛最低的兼职了 😂。每天带娃在小区广场玩的时候,顺便眼观六路耳听八方,看到迷路的骑手就上去搭把手 🚶♀️。这种零压力的“工作”,太适合全职妈妈了。我愿意打包,在这个没有KPI的岗位上,散发我所有的热量 ☀️。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/15/1",
|
||||
"width": 1080,
|
||||
"height": 1369
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 16,
|
||||
"title": "今天跟娃一起,完成了一项任务 🏆",
|
||||
"body": "今天在楼下,我故意考我家大宝:“那个骑手叔叔好像找不到楼栋了,我们带他去好不好?”大宝兴奋地在前面带路,跑得比我还快 🏃♂️。看着他自豪的小背影,我觉得这是最好的言传身教。我不仅自己找到了价值,还给娃上了一堂生动的品德课 📚。我愿意打包,带着我的小帮手,一起完成这些温暖的小任务 🌟。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/16/1",
|
||||
"width": 552,
|
||||
"height": 800
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 17,
|
||||
"title": "我这“野生导航”的名号,算是打响了 📢",
|
||||
"body": "今天有个常来我们小区的骑手,大老远看到我就喊:“姐,8栋怎么走来着,我又忘了!” 🤣 看来我这“野生导航”的名号,在他们圈子里算是打响了。作为全职妈妈,能被这么信任和依赖,我心里还挺得意的 😎。我愿意打包,继续擦亮我这块金字招牌,做他们最可靠的活地图 🗺️。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/17/1",
|
||||
"width": 719,
|
||||
"height": 1280
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 18,
|
||||
"title": "💖骑手淳朴一句谢!宝妈收获满满幸福✨",
|
||||
"body": "今天在小区门口,又 “打包” 了一个骑手小哥。他要去的那栋楼,正好是我家楼下。送到后,他感激地对我说了声 “谢谢姐!” 就这么简单的一句话,却是我最好的 “礼物”。全职妈妈的生活,有时候会觉得有点枯燥,但这些小小的善意和被需要的感觉,真的能给我带来很多快乐和动力。🥰# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈心情 #暖心礼物 #宝妈日常",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/18/1",
|
||||
"width": 1242,
|
||||
"height": 1656
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 19,
|
||||
"title": "今天被物业阿姨夸是个“热心肠” 👩🦱",
|
||||
"body": "在小区里帮骑手捡掉在地上的外卖,刚好被保洁阿姨看到了。她笑着夸我:“你这媳妇真是个热心肠,谁家娶了你真有福气。” 😆 虽然是句客套话,但听着就是舒坦!全职妈妈的价值,不就是在这些柴米油盐和热心肠里体现的吗?我愿意打包,做个热心肠的好媳妇、好邻居 🏡。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/19/1",
|
||||
"width": 480,
|
||||
"height": 848
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 20,
|
||||
"title": "我决定,把这当成我的终身事业 🎯",
|
||||
"body": "虽然这只是一份没有报酬的“兼职”,但我决定把它当成我的终身事业来做!🏆 只要我还在这个小区,只要我还能走得动,我就会一直帮下去。因为这份事业带来的快乐和满足,是任何东西都换不来的。我愿意打包,在这个平凡的岗位上,坚持做一件不平凡的小事 ✨。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/20/1",
|
||||
"width": 500,
|
||||
"height": 688
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 21,
|
||||
"title": "👩👧👦带娃当小天使!宝妈帮骑手一程❤️",
|
||||
"body": "今天带娃出门👩👧👦。在小区门口看到一个骑手小哥。我赶紧停下车:“小哥,去哪儿?我带你一段!” 娃在旁边好奇地看着,还时不时地问我:“妈妈,我们是在帮助别人吗?” 小哥有点不好意思,但还是接受了我的帮助。送到他要去的楼栋,娃还帮我按了电梯。能给娃做个好榜样,言传身教,真好!🥰# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #亲子教育 #宝妈带娃 #小天使",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/21/1",
|
||||
"width": 1242,
|
||||
"height": 1656
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 22,
|
||||
"title": "🚗宝妈小面包,接娃打包两不误🍞",
|
||||
"body": "谁说只有豪车才能 “打包”?我的家用小面包今天也成了 “打包神器”!在小区门口看到一个骑手小哥,外卖箱里装满了东西,正准备步行进小区。我赶紧停下车:“小哥,去哪儿?我带你!” 他有点惊讶,但还是把外卖箱放到了我的后座。送到楼下,他连声感谢。能用自己的车帮到别人,感觉特别有成就感!🥳# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈用车 #成就感 #小区兼职",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/22/1",
|
||||
"width": 1242,
|
||||
"height": 1656
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 23,
|
||||
"title": "💖骑手笑容太治愈!宝妈越做越开心😊",
|
||||
"body": "今天在小区门口,又 “打包” 了一个骑手小哥。他要去的那栋楼,正好是我家楼下。送到后,他感激地对我笑了笑,说了声 “谢谢姐!” 就这么简单的一个笑容,却是我最好的 “回报”。全职妈妈的生活,有时候会觉得有点枯燥,但这些小小的善意和被需要的感觉,真的能给我带来很多快乐和动力。🥰# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈心情 #暖心回报 #宝妈日常",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/23/1",
|
||||
"width": 1242,
|
||||
"height": 1656
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 24,
|
||||
"title": "🏡小区坡太陡?宝妈带骑手轻松过坡🚗",
|
||||
"body": "我们小区坡道又多又陡,每次看骑手小哥骑车往上冲都特别费劲。今天刚好出门,碰到一位小哥正慢慢爬坡,我立马摇下车窗喊他:“顺路,我捎你过去吧!”\n他又惊喜又感激,上车后一直说太谢谢了。\n能帮奔波的人少费点力气,自己心里也暖暖的。💖# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈帮忙 #小区坡道 #顺路",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/24/1",
|
||||
"width": 800,
|
||||
"height": 1419
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 25,
|
||||
"title": "🌧️宝妈出车!护骑手一路安全🌧️",
|
||||
"body": "晚风特别舒服,我接宝宝下学,开车刚到小区门口,就看到一位骑手小哥正麻利地穿梭在车流间赶单。\n我摇下车窗喊住他:“小哥,我顺路,捎你一程呀!”\n他先是一愣,随即笑着上了车,一路上还跟我聊起今天跑了好多单,特别有干劲。\n送到楼下时,他连声道谢,整个人都轻松了不少。\n原来一次小小的搭把手,就能给奔波的人多一份顺畅。\n愿每一位努力生活的小哥,都被温柔以待,一路平安顺遂✨。# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #暴雨送餐 #宝妈安全 #暖心",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/25/1",
|
||||
"width": 599,
|
||||
"height": 800
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 26,
|
||||
"title": "👩👧👦带娃当小帮手!宝妈帮骑手推车🚗",
|
||||
"body": "早上正好有空带娃一起出门,小家伙全程都要当我的 “小助手”。\n在小区门口碰到一位小哥电动车没电,只能推着走,我赶紧停下车说帮他一段。\n孩子在旁边认真地看着,还奶声奶气问我是不是在做好事,到楼下还主动帮按电梯。\n能用这样的小事给孩子做榜样,比说多少道理都管用。🥰# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #亲子教育 #宝妈带娃 #小帮手",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/26/1",
|
||||
"width": 1046,
|
||||
"height": 1390
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 27,
|
||||
"title": "🚗宝妈小轿车,接娃打包刚刚好🚗",
|
||||
"body": "谁说只有豪车才能 “打包”?我的家用代步车今天也成了 “打包神器”!在小区门口看到一个骑手小哥,外卖箱里装满了东西,正准备步行进小区。我赶紧停下车:“小哥,去哪儿?我带你!” 他有点惊讶,但还是把外卖箱放到了我的后座。送到楼下,他连声感谢。能用自己的车帮到别人,感觉特别有成就感!🥳# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈用车 #成就感 #小区兼职",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/27/1",
|
||||
"width": 1242,
|
||||
"height": 1656
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 28,
|
||||
"title": "💖骑手真诚道谢!宝妈心里超温暖✨",
|
||||
"body": "今天在小区门口,又 “打包” 了一个骑手小哥。他要去的那栋楼,正好是我家楼下。送到后,他感激地对我说了声 “谢谢姐!” 就这么简单的一句话,却是我最好的 “慰藉”。全职妈妈的生活,有时候会觉得有点枯燥,但这些小小的善意和被需要的感觉,真的能给我带来很多快乐和动力。🥰# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈心情 #暖心慰藉 #宝妈日常",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/28/1",
|
||||
"width": 576,
|
||||
"height": 795
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 29,
|
||||
"title": "🏡小区九曲弯?宝妈带骑手抄近路🛣️",
|
||||
"body": "我们小区里有很多九曲十八弯的小路,每次骑手小哥来送外卖,都要绕好大一圈。今天在小区门口看到一个骑手小哥,看着地图一脸无奈。我赶紧摇下车窗:“小哥,去哪儿?我带你走近路!” 他惊喜地上了车,送到后连声感谢。能帮他们省点时间,少走弯路,我也觉得挺好的。💖# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈指路 #小区弯路 #互助",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/29/1",
|
||||
"width": 1080,
|
||||
"height": 1440
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 30,
|
||||
"title": "宝妈门口出车!给骑手一路开心❄️",
|
||||
"body": "阳光正好的午后,我开车到小区附近,看到一位骑手小哥正轻快地赶路,正好要进我们小区\n我笑着靠边停车,朝他挥挥手:“小哥,我顺路,带你一程呀!”\n 他有些惊喜地坐上车,车里暖暖的,一路上都在开心地道谢。\n送到楼下时,他连连道谢,整个人都轻松了许多。\n 能在平凡的日常里,给努力生活的人多一份便利与温柔,自己心里也满是欢喜。\n愿每一位奔波的小哥,都一路顺遂,平安喜乐✨\n\n\n# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #雪天送餐 #宝妈暖心 #温暖",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/30/1",
|
||||
"width": 1050,
|
||||
"height": 1396
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 31,
|
||||
"title": "带娃当小棉袄!宝妈帮骑手一程❤️",
|
||||
"body": "今天带娃出门逛街👩👧👦,小家伙主动要当我的专属小帮手。\n在小区门口碰到一位骑手小哥,正慢悠悠地推着电动车往前走,打算就近找地方充电。\n我笑着停下车喊他:“小哥,我帮你搭一段路吧!”\n娃在旁边睁着大眼睛,奶声奶气地问:“妈妈,我们是不是在做好事呀?\n到了楼栋口,小家伙还踮着脚帮我按了电梯。\n用这样小小的陪伴与举手之劳,给孩子上一堂生动的善良课,比讲再多道理都管用🥰。# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #亲子教育 #宝妈带娃 #小棉袄",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/31/1",
|
||||
"width": 1042,
|
||||
"height": 1388
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 32,
|
||||
"title": "宝妈代步车,接娃打包超方便🚗",
|
||||
"body": "开车也要传递温暖~我的家用小轿车今天也变身暖心 “摆渡车” 啦!\n在小区门口碰到一位骑手小哥,餐箱满满当当,正准备步行进去配送。我立马摇下车窗:“小哥,上来吧,我送你进去!”\n他又惊喜又客气,小心地把外卖箱放到后座。送到楼下后,小哥一个劲地道谢,笑容特别真诚。\n能用自己的小车帮奔波的人省点时间、少走点路,真的超有成就感!# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈用车 #成就感 #小区兼职",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/32/1",
|
||||
"width": 1058,
|
||||
"height": 1398
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 33,
|
||||
"title": "💖骑手朴实道谢!宝妈越做越有动力✨",
|
||||
"body": "他拎着外卖找不着路口,刚好我熟悉附近路线。指引完,他笑着跟我说了声 “太感谢啦!” 就这么简单的一句话,却是我最暖的 “慰藉”。平凡日常的生活,偶尔会觉得平淡乏味,但这些小小的善意和被需要的感觉,真的能给我带来很多温暖和力量。🥰# 随手助人 #暖心瞬间 #平凡生活 #日常小美好 #人间温暖 #生活感悟",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/33/1",
|
||||
"width": 868,
|
||||
"height": 1280
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 34,
|
||||
"title": "🏡小区陡坡难走?宝妈带骑手轻松过⛰️",
|
||||
"body": "今天在小区门口正好碰到一个小哥,正爬坡呢。我赶紧摇下车窗喊他:“小哥,你去哪边?我捎你一段!”\n\n他当时可惊喜了,马上坐上我的车。送到之后一直跟我说谢谢。能帮他们省点力气,我自己心里也暖暖的,特别开心。💖\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #宝妈帮忙 #小区陡坡 #顺路",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/34/1",
|
||||
"width": 443,
|
||||
"height": 851
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 35,
|
||||
"title": "🌪️大风天宝妈出车!稳送骑手一程🌪️",
|
||||
"body": "今天在小区门口时,远远看见一个外卖小哥。\n\n我赶紧靠边停下车,摇下车窗喊他:“小哥,快上车吧!”\n\n他一开始还有点犹豫,后来还是上了车。车里暖烘烘的,他轻声说了句谢谢,声音被风吹得有些沙哑。\n\n送到楼下后,他一个劲地鞠躬道谢。给他一段平稳安稳的路,我心里也踏实多了。\n\n真心希望每一位奔波在外的小哥,都能平平安安回家。🙏\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #大风送餐 #宝妈暖心 #平安出行",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/35/1",
|
||||
"width": 624,
|
||||
"height": 825
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 36,
|
||||
"title": "👩👧👦带娃当小暖炉!宝妈帮骑手一程❤️",
|
||||
"body": "今天带娃出门👩👧👦,开到小区门口,看见一个外卖小哥,我赶紧停下车让他上来,带他进小区送单。\n\n娃在旁边一脸认真地看着,还小声问我:“妈妈,我们是不是在帮助别人呀?”我说:“是呀,我们这叫乐于助人!”\n\n既能帮到别人,又能给孩子做个好榜样,这种言传身教的感觉,真的太暖了🥰\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #亲子教育 #宝妈带娃 #小暖炉",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/36/1",
|
||||
"width": 585,
|
||||
"height": 840
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 37,
|
||||
"title": "宝妈买菜车,顺手打包骑手好方便🛒",
|
||||
"body": "谁说非得豪车才能“打包骑手”?\n我这台平平常常的买菜小破车,今天直接变身打包神器了!🥳\n\n在小区门口碰到个外卖小哥,外卖箱在后面放着,看样子准备走路进小区送餐。\n我赶紧一脚刹车停边上:“上车不?稍你一下”\n\n他当时都愣了一下,有点意外,后来还是开开心心把外卖箱放我后座,坐上车了。\n送到楼下以后,一个劲儿跟我说谢谢。\n\n其实就是顺手的事儿,能用自己的小车帮上别人忙,真的超有成就感!\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈买菜 #成就感 #小区兼职",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/37/1",
|
||||
"width": 1048,
|
||||
"height": 1400
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 38,
|
||||
"title": "💖骑手一句理解!宝妈心里超感动✨",
|
||||
"body": "今天出门又在小区里碰到一个骑手小哥,看着他抱着一堆外卖匆匆忙忙赶路,我就顺口喊了一句:“小哥,去哪栋?我捎你过去!”\n\n 他一开始还有点不敢相信,笑着坐上了车。\n送到地方,他特别真诚地说了句:“姐,太谢谢你了!”\n\n 就这一句话,我心里瞬间暖暖的。\n全职在家带娃的日子,每天围着家里转,有时候真觉得自己没什么价值,有点没劲。\n但每次帮到这些奔波的小哥,被人真心实意地需要一次,就觉得特别有意义,整个人都充满劲儿了。\n原来小小的善意,真的能治愈平凡日子里的小疲惫。🥰\n\n #打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈日常 #暖心瞬间 #全职妈妈心情",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/38/1",
|
||||
"width": 1728,
|
||||
"height": 2304
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 39,
|
||||
"title": "小区 Z 字路?宝妈带骑手抄近路🛣️",
|
||||
"body": "我们小区全是那种拐来拐去的Z字小路,外卖小哥进来基本都懵,每次都要绕一大圈冤枉路。\n\n今天在小区门口就碰到一个小哥,盯着地图一脸无奈,一看就是绕晕了。\n我赶紧摇下车窗喊他:“小哥,你去哪栋?我带你抄近路!”\n\n他当时眼睛都亮了,特别惊喜地上了车。\n送到地方以后,一个劲儿跟我说谢谢。\n\n能帮他们省点时间、少走点弯路,我自己心里也美滋滋的~💖\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈指路 #小区Z字路 #邻里互助",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/39/1",
|
||||
"width": 1052,
|
||||
"height": 1394
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 40,
|
||||
"title": "☀️清晨宝妈出车!给骑手满满活力☀️",
|
||||
"body": "今天清晨,阳光明媚☀️。送娃上学路上,看到一个骑手小哥,精神抖擞地在赶路。我心里一动,赶紧靠边停车:“小哥,去哪儿?我带你一程!” 他犹豫了一下,还是上了车。车里放着轻快的音乐,小哥说了声谢谢。送到楼下,他连连鞠躬。能在这个清晨给他一份活力,也让我自己多了一份安心。希望每个小哥都能平安!🙏# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #清晨送餐 #宝妈活力 #暖心",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/40/1",
|
||||
"width": 573,
|
||||
"height": 840
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 41,
|
||||
"title": "👩👧👦带娃当小司机!宝妈帮骑手一程🚗",
|
||||
"body": "带娃在小区遛弯的日常,遇见正在送单的骑手,举手之劳帮他带了路。\n孩子天真的提问、主动按电梯的模样,都在告诉我:\n善良是可以传承的。\n\n能在平凡日子里,给孩子做一个温柔的榜样,真的很幸福。\n\n#打包骑手 #外卖小哥 #宝妈心情 #亲子时光",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/41/1",
|
||||
"width": 998,
|
||||
"height": 1330
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 42,
|
||||
"title": "🚗宝妈 小轿车,接娃打包超实用🚗",
|
||||
"body": "谁说只有豪车才能 “打包骑手” 啊?\n我这台家用 小轿车,今天直接变身打包神器!🥳\n\n在小区门口碰到个外卖小哥,外卖箱塞得满满当当,看样子打算走路进小区送餐。\n我赶紧停下车喊他:“小哥,你去哪啊,要不要载你!”\n\n他当时还愣了一下,有点意外,后来开开心心把外卖箱放我后座,坐上车了。\n送到楼下,一个劲儿跟我说谢谢。\n\n其实就是顺手的事儿,能用自己的小车帮上忙,真的超有成就感!\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈用车 #成就感 #小区兼职",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/42/1",
|
||||
"width": 1039,
|
||||
"height": 1416
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 43,
|
||||
"title": "💖骑手真心感激!宝妈收获满满幸福✨",
|
||||
"body": "今天在小区门口,又 “打包” 了一个骑手小哥。他要去的那栋楼,正好是我家楼下。送到后,他感激地对我说了声 “谢谢姐!” 就这么简单的一句话,却是我最好的 “回馈”。全职妈妈的生活,有时候会觉得有点枯燥,但这些小小的善意和被需要的感觉,真的能给我带来很多快乐和动力。🥰# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈心情 #暖心回馈 #宝妈日常",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/43/1",
|
||||
"width": 432,
|
||||
"height": 597
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 44,
|
||||
"title": "🏡小区死胡同?宝妈带骑手抄近路🚧",
|
||||
"body": "我们小区里有很多死胡同,每次骑手小哥来送外卖,都要绕好大一圈。今天在小区门口看到一个骑手小哥,看着地图一脸无奈。我赶紧摇下车窗:“小哥,去哪儿?我带你走近路!” 他惊喜地上了车,送到后连声感谢。能帮他们省点时间,少走弯路,我也觉得挺好的。💖# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈指路 #小区死胡同 #互助",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/44/1",
|
||||
"width": 4472,
|
||||
"height": 7952
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 45,
|
||||
"title": "宝妈出车!给骑手一路温暖🌧️",
|
||||
"body": "快到小区门口时,远远看见一个外卖小哥在送外卖,赶紧靠边停车喊他:“小哥,你去哪?我捎你一程!”\n\n 他一开始还有点犹豫,后来还是上了车。\n车里暖气很足,他小声说了句谢谢,声音都有点哑。\n\n 送到楼下,能给小哥一点温暖,我自己心里也踏实很多。\n\n #打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #小雨送餐 #宝妈暖心 #温暖",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/45/1",
|
||||
"width": 1062,
|
||||
"height": 1411
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 46,
|
||||
"title": "👩👧👦带娃当小向导!宝妈帮骑手一程❤️",
|
||||
"body": "刚到小区门口,就看见一个外卖小哥进不去送单,我想这是言传身教的好机会。\n我赶紧停下车问他:“小哥,你去哪栋?我帮你一段!”\n小哥一开始还有点不好意思,后来也放心地接受了帮忙。\n到楼下的时候,娃还主动跑过去帮我按了电梯。\n\n能顺手帮到奔波的人,还能给孩子做个最真实的好榜样,这种言传身教的感觉真的太暖了🥰\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #亲子教育 #宝妈带娃 #小向导",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/46/1",
|
||||
"width": 1086,
|
||||
"height": 1448
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 47,
|
||||
"title": "🚗宝妈小货车,帮骑手运货超方便🚗",
|
||||
"body": "谁说只有豪车才能 “打包”?我的家用小货车今天也成了 “打包神器”!在小区门口看到一个骑手小哥,外卖箱里装满了东西,正准备步行进小区。我赶紧停下车:“小哥,去哪儿?我带你!” 他有点惊讶,但还是把外卖箱放到了我的后座。送到楼下,他连声感谢。能用自己的车帮到别人,感觉特别有成就感!🥳# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈用车 #成就感 #小区兼职",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/47/1",
|
||||
"width": 800,
|
||||
"height": 1067
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 48,
|
||||
"title": "💖骑手淳朴道谢!宝妈心里甜滋滋✨",
|
||||
"body": "又顺手 “打包” 了一个骑手小哥。\n他要去的楼栋,正好就在我家楼下,特别顺路。\n\n送到后,他真诚地跟我说了句:“谢谢啊!”\n\n一句简单的感谢,却成了我今天最温暖的收获。\n全职妈妈的日常偶尔会有些枯燥,但这份小小的善意,和被人需要的感觉,真的能带来满满的快乐和力量。\n原来举手之劳,也能双向温暖✨🥰\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈心情 #暖心收获 #宝妈日常 #平凡生活里的小善意",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/48/1",
|
||||
"width": 1076,
|
||||
"height": 1421
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 49,
|
||||
"title": "🏡小区绿化带挡路?宝妈带骑手抄近路🌳",
|
||||
"body": "我们小区里有很多绿化带,每次骑手小哥来送外卖,都要绕好大一圈。今天在小区门口看到一个骑手小哥,看着地图一脸无奈。我赶紧摇下车窗:“小哥,去哪儿?我带你走近路!” 他惊喜地上了车,送到后连声感谢。能帮他们省点时间,少走弯路,我也觉得挺好的。💖# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈指路 #小区绿化带 #互助",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/49/1",
|
||||
"width": 800,
|
||||
"height": 1067
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 50,
|
||||
"title": "☀️朝阳里宝妈出车!给骑手一份希望☀️",
|
||||
"body": "今天朝阳初升☀️,天气也渐渐暖和了。今天送娃回家路上,看到一个骑手小哥,还在赶路。我心里一动,赶紧靠边停车:“小哥,去哪儿?我带你一程!” 他犹豫了一下,还是上了车。车里暖气开着,小哥说了声谢谢,声音有点沙哑。送到楼下,他连连鞠躬。能在这个朝阳中给他一份希望,也让我自己多了一份安心。希望每个小哥都能平安!🙏# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #朝阳送餐 #宝妈暖心 #希望",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/50/1",
|
||||
"width": 579,
|
||||
"height": 772
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 51,
|
||||
"title": "🚗宝妈小巴士,接娃打包超合适🚌",
|
||||
"body": "我的家用小巴士,今天直接变身暖心打包神器!🥳\n\n在小区门口碰到一个骑手小哥,外卖箱装得满满当当,打算走路进小区送餐。\n我赶紧停下车喊他:“小哥,你去哪栋?我捎你一段!”\n\n他先是一愣,有点意外,随后把外卖箱放到后座,开心地上了车。\n送到楼下后,他不停地跟我说谢谢。\n\n不用多厉害的车,只是一点小小的善意,就能帮到奔波的人。\n这种双向的温暖,真的让人快乐又有成就感。💛\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈用车 #成就感 #平凡小善意",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/51/1",
|
||||
"width": 1086,
|
||||
"height": 1448
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 52,
|
||||
"title": "💖骑手善意暖心!宝妈越活越有光✨",
|
||||
"body": "顺手 “打包” 了一个骑手小哥。\n他看了看地址,刚好要去我家楼下那栋,简直是无缝顺路。\n\n把他送到楼下后,小哥特别真诚地说了一句:“你人真好!”\n就这一句简单又朴实的感谢,没有华丽的词藻,却像一股暖流,一点点滋养着我日常的心情。\n\n其实全职妈妈的生活,大多时候都是围着家庭打转,重复又琐碎,偶尔也会觉得枯燥、找不到价值感。\n但每次这样小小的举手之劳,收获一句真心的谢谢,感受到自己被需要、能给别人带去方便,心里就特别踏实、特别快乐。\n\n这些细碎的善意,看似不起眼,却在悄悄治愈着平凡的日子,也给了我继续温柔生活的满满动力。🥰\n\n#打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈心情 #暖心滋养 #宝妈日常 #平凡生活里的小美好",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/52/1",
|
||||
"width": 1072,
|
||||
"height": 1404
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 53,
|
||||
"title": "🏡地下车库绕晕?宝妈带骑手找对路🅿️",
|
||||
"body": "我们小区里有几个地下车库,每次骑手小哥来送外卖,都要绕好大一圈。今天在小区门口看到一个骑手小哥,看着地图一脸无奈。我赶紧摇下车窗:“小哥,去哪儿?我带你走近路!” 他惊喜地上了车,送到后连声感谢。能帮他们省点时间,少走弯路,我也觉得挺好的。💖# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈指路 #地下车库 #互助",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/53/1",
|
||||
"width": 599,
|
||||
"height": 800
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 54,
|
||||
"title": "🌙月光下宝妈出车!给骑手一份安心🌙",
|
||||
"body": "今天月光皎洁🌙,天气也渐渐凉了。今天送娃回家路上,看到一个骑手小哥,还在赶路。我心里一动,赶紧靠边停车:“小哥,去哪儿?我带你一程!” 他犹豫了一下,还是上了车。车里暖气开着,小哥说了声谢谢,声音有点沙哑。送到楼下,他连连鞠躬。能在这个月光中给他一份安心,也让我自己多了一份温暖。希望每个小哥都能平安!🙏# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #月光送餐 #宝妈安心 #温暖",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/54/1",
|
||||
"width": 904,
|
||||
"height": 1200
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 55,
|
||||
"title": "🚗宝妈小跑车,顺路打包骑手超酷🚗",
|
||||
"body": "我这台家用小跑车,今天也当了一回“骑手的骑手”。\n刚到小区门口,看到一个外卖小哥,箱子塞得满满当当,还要步行进去。那一瞬间就觉得——太辛苦了。\n我直接停下车喊他:“小哥,去哪栋?我顺路带你!”\n 他明显愣了一下,但还是把外卖箱放到了后座。\n送到楼下,他一路都在说谢谢。\n 其实也就是顺手的事,但那一刻真的有种说不出来的满足感。\n原来,帮别人一把,比自己开心更开心。🥳\n#骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈日常 #小区生活 #温暖瞬间 #普通人的善意",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/55/1",
|
||||
"width": 1058,
|
||||
"height": 1416
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 56,
|
||||
"title": "💖宝妈动力满满向前冲✨",
|
||||
"body": "骑手小哥🚗\n他要去的那栋楼,刚好就在我家楼下。\n 我顺路带了他一程。\n下车的时候,他很认真地说了一句:\n “谢谢姐!”\n就这么简单的一句话,\n 却莫名让我开心了很久。\n全职妈妈的生活,有时候确实会有点重复、甚至有点枯燥。\n 但这些不经意的小瞬间——\n 被需要、被感谢、被回应,\n真的会变成我一天里最有“能量”的时刻🥰\n原来,让别人轻松一点,\n 自己也会更快乐一点。\n#骑手日常 #外卖小哥 #当了一把骑手的骑手 #宝妈生活 #被需要的感觉 #生活里的小确幸 #普通人的善意",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/56/1",
|
||||
"width": 1048,
|
||||
"height": 1402
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 57,
|
||||
"title": "🏡小区林荫道?宝妈带骑手抄近路🌳",
|
||||
"body": "我们小区的林荫道特别多🌿\n 看起来很舒服,但对骑手来说,真的太容易绕路了。\n今天在门口看到一个骑手小哥,\n 盯着手机地图,一脸“我到底在哪儿”的无奈。\n我忍不住摇下车窗:“小哥,去哪栋?我带你走近路!”\n 他愣了一下,然后立马笑了,赶紧上车。\n送到之后,他一路都在说谢谢。\n 其实我也没做什么,只是帮他少绕了一圈路。\n但那一刻真的会觉得——\n 自己好像,也在让这个世界更顺一点点💖\n#骑手日常 #外卖小哥 #宝妈生活 #小区日常 #普通人的善意 #生活里的温柔 #互帮互助",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/57/1",
|
||||
"width": 1052,
|
||||
"height": 1400
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 58,
|
||||
"title": "我的小电驴,成了小区的专属摆渡车",
|
||||
"body": "自从买了这个带儿童座椅的小电驴,接送娃方便多了 。今天送完娃回小区,刚好看到个小哥提着大包小包在找单元门。我直接一脚刹车停在他面前:“上来,姐带你过去!” \n\n小哥愣了一下,然后开心地坐了上来。到了楼下,他非要送我一瓶水表示感谢。我愿意打包,反正车座空着也是空着,顺手捎一程,这种充满人情味的互动,让我觉得全职妈妈的生活也很有趣 。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/58/1",
|
||||
"width": 1046,
|
||||
"height": 1394
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 59,
|
||||
"title": "🎒接娃路上!宝妈顺风车再打包骑手🚗",
|
||||
"body": "每天下午接娃放学🏫,路上总能遇到急匆匆的骑手小哥。今天看到一个外卖箱都快装不下了,我赶紧停下车:“小哥,去哪儿?我带你!” 他有点不好意思,但还是把外卖放到了我的后座。送到楼下,他连声感谢。能用接娃的空档期帮到别人,感觉自己也挺有用的!😊# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈日常 #接娃顺风车 #顺路",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/59/1",
|
||||
"width": 1067,
|
||||
"height": 1431
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 60,
|
||||
"title": "这大概是我拿过,最满意的“退休金” 💰",
|
||||
"body": "刚开始送外卖时,有一次因为迷路急哭了,觉得自己什么都做不好 😭。今天在小区里看到个年轻女孩送外卖,眼眶红红的在找路。我赶紧上前,不仅带她找到了地方,还安慰了她几句 💖。🌈。\n\n#打包骑手# #骑手 #美团骑手#外卖小哥##当了一把骑手的骑手",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/60/1",
|
||||
"width": 531,
|
||||
"height": 774
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sourceRow": 61,
|
||||
"title": "🏡小区像迷宫?宝妈带骑手抄近路🗺️",
|
||||
"body": "我们小区的路,真的很“治愈”——\n 林荫道、弯弯绕绕、看起来很舒服🌿\n 但对骑手来说,其实更像个迷宫。\n今天在小区门口,看到一个骑手小哥,\n 盯着手机地图来回看,整个人有点懵。\n我一看就知道——肯定是第一次进我们小区。\n我直接摇下车窗:“小哥,去哪栋?我带你走近路!”\n 他先是愣了一下,然后立马笑了,赶紧上车。\n一路上我给他指路:“这边走可以少绕一圈,这条是近路。”\n 他边听边点头,还小声说:“这个小区真的太绕了……”\n送到楼下,他连声说谢谢,\n 那一刻其实挺有感触的——\n有时候,你只是知道一条“近路”,\n 对别人来说,就是省下的时间、少走的弯路。\n全职妈妈的生活,可能没有太多“高光时刻”,\n 但这种被需要的小瞬间,\n真的会让人觉得——\n 自己也在悄悄帮这个世界变得更顺一点点💖# 打包骑手 #骑手 #美团骑手 #外卖小哥 #当了一把骑手的骑手 #宝妈指路 #小区迷宫 #互助",
|
||||
"images": [
|
||||
{
|
||||
"index": 1,
|
||||
"key": "content-assets/954953/61/1",
|
||||
"width": 456,
|
||||
"height": 756
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
1053
lib/mcp-collection-client.ts
Normal file
1053
lib/mcp-collection-client.ts
Normal file
File diff suppressed because it is too large
Load Diff
721
lib/mvp-db.ts
Normal file
721
lib/mvp-db.ts
Normal file
@@ -0,0 +1,721 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import feishuSnapshot from "./feishu-source-snapshot.json";
|
||||
|
||||
type D1ResultRow = Record<string, unknown>;
|
||||
|
||||
export function getRawDb(): D1Database {
|
||||
const database = (env as unknown as { DB?: D1Database }).DB;
|
||||
if (!database) {
|
||||
throw new Error("数据库尚未连接");
|
||||
}
|
||||
return database;
|
||||
}
|
||||
|
||||
export function getUploadBucket(): R2Bucket {
|
||||
const bucket = (env as unknown as { UPLOADS?: R2Bucket }).UPLOADS;
|
||||
if (!bucket) {
|
||||
throw new Error("文件存储尚未连接");
|
||||
}
|
||||
return bucket;
|
||||
}
|
||||
|
||||
export async function ensureSchema(database?: D1Database) {
|
||||
const db = database ?? getRawDb();
|
||||
const statements = [
|
||||
`CREATE TABLE IF NOT EXISTS partners (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
wecom_name TEXT NOT NULL,
|
||||
owner TEXT NOT NULL DEFAULT '运营组',
|
||||
claimed_total INTEGER NOT NULL DEFAULT 0,
|
||||
completed_total INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
brand TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
claimed_quantity INTEGER NOT NULL DEFAULT 0,
|
||||
due_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
source_url TEXT NOT NULL DEFAULT '',
|
||||
source_sheet_id TEXT NOT NULL DEFAULT '',
|
||||
source_sheet_name TEXT NOT NULL DEFAULT '',
|
||||
source_synced_at TEXT,
|
||||
share_token TEXT,
|
||||
collection_start_date TEXT,
|
||||
collection_days TEXT NOT NULL DEFAULT '[]',
|
||||
collection_schedule_updated_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS contents (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
image_assets TEXT NOT NULL DEFAULT '[]',
|
||||
status TEXT NOT NULL DEFAULT 'available',
|
||||
source TEXT NOT NULL DEFAULT '飞书内容表',
|
||||
source_row INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
platform TEXT NOT NULL DEFAULT '小红书',
|
||||
platform_uid TEXT NOT NULL,
|
||||
public_account_id TEXT NOT NULL DEFAULT '',
|
||||
nickname TEXT NOT NULL,
|
||||
profile_url TEXT NOT NULL DEFAULT '',
|
||||
ip_location TEXT NOT NULL DEFAULT '待识别',
|
||||
followers INTEGER NOT NULL DEFAULT 0,
|
||||
post_count INTEGER NOT NULL DEFAULT 0,
|
||||
avg_views INTEGER NOT NULL DEFAULT 0,
|
||||
first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS accounts_platform_uid_idx
|
||||
ON accounts(platform, platform_uid)`,
|
||||
`CREATE TABLE IF NOT EXISTS claims (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
partner_id TEXT NOT NULL,
|
||||
claimant_name TEXT NOT NULL,
|
||||
claim_token TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS delegation_bundles (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
claim_id TEXT NOT NULL,
|
||||
partner_id TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
share_token TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
revoked_at TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS distributions (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
content_id TEXT NOT NULL,
|
||||
partner_id TEXT NOT NULL,
|
||||
claim_id TEXT,
|
||||
delegation_bundle_id TEXT,
|
||||
account_id TEXT,
|
||||
publish_url TEXT,
|
||||
publish_time TEXT,
|
||||
publish_screenshot_key TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'claimed',
|
||||
claimed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
screenshot_key TEXT,
|
||||
ocr_status TEXT NOT NULL DEFAULT 'none',
|
||||
exposure INTEGER,
|
||||
views INTEGER,
|
||||
d2_likes INTEGER,
|
||||
d2_comments INTEGER,
|
||||
d2_collects INTEGER,
|
||||
d5_likes INTEGER,
|
||||
d5_comments INTEGER,
|
||||
d5_collects INTEGER,
|
||||
d7_likes INTEGER,
|
||||
d7_comments INTEGER,
|
||||
d7_collects INTEGER,
|
||||
latest_likes INTEGER,
|
||||
latest_comments INTEGER,
|
||||
latest_collects INTEGER,
|
||||
collection_status TEXT NOT NULL DEFAULT 'pending',
|
||||
collection_status_description TEXT,
|
||||
collection_updated_at TEXT,
|
||||
last_collection_day INTEGER,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS collection_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
distribution_id TEXT NOT NULL,
|
||||
scheduled_date TEXT NOT NULL,
|
||||
schedule_day INTEGER,
|
||||
scheduled_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
likes INTEGER,
|
||||
comments INTEGER,
|
||||
collects INTEGER,
|
||||
status_description TEXT,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
];
|
||||
|
||||
for (const statement of statements) {
|
||||
await db.prepare(statement).run();
|
||||
}
|
||||
|
||||
const ensureColumn = async (
|
||||
table: string,
|
||||
column: string,
|
||||
definition: string,
|
||||
) => {
|
||||
const info = await db
|
||||
.prepare(`PRAGMA table_info(${table})`)
|
||||
.all<{ name: string }>();
|
||||
if (!info.results.some((item) => item.name === column)) {
|
||||
await db.prepare(`ALTER TABLE ${table} ADD COLUMN ${definition}`).run();
|
||||
}
|
||||
};
|
||||
|
||||
await ensureColumn("tasks", "source_url", "source_url TEXT NOT NULL DEFAULT ''");
|
||||
await ensureColumn(
|
||||
"tasks",
|
||||
"source_sheet_id",
|
||||
"source_sheet_id TEXT NOT NULL DEFAULT ''",
|
||||
);
|
||||
await ensureColumn(
|
||||
"tasks",
|
||||
"source_sheet_name",
|
||||
"source_sheet_name TEXT NOT NULL DEFAULT ''",
|
||||
);
|
||||
await ensureColumn("tasks", "source_synced_at", "source_synced_at TEXT");
|
||||
await ensureColumn("tasks", "share_token", "share_token TEXT");
|
||||
await ensureColumn(
|
||||
"tasks",
|
||||
"collection_start_date",
|
||||
"collection_start_date TEXT",
|
||||
);
|
||||
await ensureColumn(
|
||||
"tasks",
|
||||
"collection_days",
|
||||
"collection_days TEXT NOT NULL DEFAULT '[]'",
|
||||
);
|
||||
await ensureColumn(
|
||||
"tasks",
|
||||
"collection_schedule_updated_at",
|
||||
"collection_schedule_updated_at TEXT",
|
||||
);
|
||||
await ensureColumn("contents", "source_row", "source_row INTEGER");
|
||||
await ensureColumn(
|
||||
"contents",
|
||||
"image_assets",
|
||||
"image_assets TEXT NOT NULL DEFAULT '[]'",
|
||||
);
|
||||
await ensureColumn(
|
||||
"accounts",
|
||||
"public_account_id",
|
||||
"public_account_id TEXT NOT NULL DEFAULT ''",
|
||||
);
|
||||
await ensureColumn("distributions", "claim_id", "claim_id TEXT");
|
||||
await ensureColumn(
|
||||
"distributions",
|
||||
"delegation_bundle_id",
|
||||
"delegation_bundle_id TEXT",
|
||||
);
|
||||
await ensureColumn(
|
||||
"distributions",
|
||||
"publish_screenshot_key",
|
||||
"publish_screenshot_key TEXT",
|
||||
);
|
||||
await ensureColumn(
|
||||
"distributions",
|
||||
"latest_likes",
|
||||
"latest_likes INTEGER",
|
||||
);
|
||||
await ensureColumn(
|
||||
"distributions",
|
||||
"latest_comments",
|
||||
"latest_comments INTEGER",
|
||||
);
|
||||
await ensureColumn(
|
||||
"distributions",
|
||||
"latest_collects",
|
||||
"latest_collects INTEGER",
|
||||
);
|
||||
await ensureColumn(
|
||||
"distributions",
|
||||
"collection_status",
|
||||
"collection_status TEXT NOT NULL DEFAULT 'pending'",
|
||||
);
|
||||
await ensureColumn(
|
||||
"distributions",
|
||||
"collection_status_description",
|
||||
"collection_status_description TEXT",
|
||||
);
|
||||
await ensureColumn(
|
||||
"distributions",
|
||||
"collection_updated_at",
|
||||
"collection_updated_at TEXT",
|
||||
);
|
||||
await ensureColumn(
|
||||
"distributions",
|
||||
"last_collection_day",
|
||||
"last_collection_day INTEGER",
|
||||
);
|
||||
|
||||
const tasksWithoutShare = await db
|
||||
.prepare(
|
||||
"SELECT id FROM tasks WHERE share_token IS NULL OR share_token = ''",
|
||||
)
|
||||
.all<{ id: string }>();
|
||||
for (const task of tasksWithoutShare.results) {
|
||||
await db
|
||||
.prepare("UPDATE tasks SET share_token = ? WHERE id = ?")
|
||||
.bind(crypto.randomUUID().replaceAll("-", ""), task.id)
|
||||
.run();
|
||||
}
|
||||
await db
|
||||
.prepare(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS tasks_share_token_idx ON tasks(share_token)",
|
||||
)
|
||||
.run();
|
||||
await db
|
||||
.prepare(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS claims_claim_token_idx ON claims(claim_token)",
|
||||
)
|
||||
.run();
|
||||
await db
|
||||
.prepare(
|
||||
`CREATE INDEX IF NOT EXISTS claims_task_partner_created_idx
|
||||
ON claims(task_id, partner_id, created_at)`,
|
||||
)
|
||||
.run();
|
||||
await db
|
||||
.prepare(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS delegation_bundles_share_token_idx
|
||||
ON delegation_bundles(share_token)`,
|
||||
)
|
||||
.run();
|
||||
await db
|
||||
.prepare(
|
||||
`CREATE INDEX IF NOT EXISTS delegation_bundles_claim_created_idx
|
||||
ON delegation_bundles(claim_id, created_at)`,
|
||||
)
|
||||
.run();
|
||||
await db
|
||||
.prepare(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS collection_runs_distribution_date_idx
|
||||
ON collection_runs(distribution_id, scheduled_date)`,
|
||||
)
|
||||
.run();
|
||||
await db
|
||||
.prepare(
|
||||
`CREATE INDEX IF NOT EXISTS collection_runs_task_date_idx
|
||||
ON collection_runs(task_id, scheduled_date)`,
|
||||
)
|
||||
.run();
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE distributions
|
||||
SET latest_likes = COALESCE(d7_likes, d5_likes, d2_likes),
|
||||
latest_comments = COALESCE(d7_comments, d5_comments, d2_comments),
|
||||
latest_collects = COALESCE(d7_collects, d5_collects, d2_collects),
|
||||
collection_status = 'success',
|
||||
collection_status_description = '历史采集数据已迁移',
|
||||
collection_updated_at = updated_at,
|
||||
last_collection_day = CASE
|
||||
WHEN d7_likes IS NOT NULL THEN 7
|
||||
WHEN d5_likes IS NOT NULL THEN 5
|
||||
WHEN d2_likes IS NOT NULL THEN 2
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE latest_likes IS NULL
|
||||
AND COALESCE(d7_likes, d5_likes, d2_likes) IS NOT NULL`,
|
||||
)
|
||||
.run();
|
||||
|
||||
const tasksNeedingImages = await db
|
||||
.prepare(
|
||||
`SELECT DISTINCT t.id
|
||||
FROM tasks t
|
||||
JOIN contents c ON c.task_id = t.id
|
||||
WHERE t.source_sheet_id = ?
|
||||
AND (c.image_assets IS NULL OR c.image_assets = '' OR c.image_assets = '[]')`,
|
||||
)
|
||||
.bind(feishuSnapshot.sheetId)
|
||||
.all<{ id: string }>();
|
||||
for (const task of tasksNeedingImages.results) {
|
||||
const updates = feishuSnapshot.rows
|
||||
.filter((row) => row.images.length > 0)
|
||||
.map((row) =>
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE contents SET image_assets = ?
|
||||
WHERE task_id = ? AND source_row = ?
|
||||
AND (image_assets IS NULL OR image_assets = '' OR image_assets = '[]')`,
|
||||
)
|
||||
.bind(JSON.stringify(row.images), task.id, row.sourceRow),
|
||||
);
|
||||
if (updates.length > 0) await db.batch(updates);
|
||||
}
|
||||
}
|
||||
|
||||
export async function seedIfEmpty() {
|
||||
const db = getRawDb();
|
||||
const row = await db.prepare("SELECT COUNT(*) AS count FROM tasks").first<{
|
||||
count: number;
|
||||
}>();
|
||||
if ((row?.count ?? 0) > 0) return;
|
||||
|
||||
const batch = [
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO partners
|
||||
(id, name, wecom_name, owner, claimed_total, completed_total)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind("partner-linlin", "林林KOC社群", "林林|母婴社群", "小吴", 24, 18),
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO partners
|
||||
(id, name, wecom_name, owner, claimed_total, completed_total)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind("partner-muzi", "木子", "木子同学", "小陈", 8, 7),
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO partners
|
||||
(id, name, wecom_name, owner, claimed_total, completed_total)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind("partner-xiaolu", "小鹿内容组", "鹿鹿日常", "小吴", 15, 12),
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO tasks
|
||||
(id, name, brand, quantity, claimed_quantity, due_at, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
"task-summer",
|
||||
"夏日轻盈计划",
|
||||
"青柠实验室",
|
||||
30,
|
||||
9,
|
||||
"2026-08-05",
|
||||
"active",
|
||||
),
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO tasks
|
||||
(id, name, brand, quantity, claimed_quantity, due_at, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
"task-living",
|
||||
"理想生活家",
|
||||
"木屿家居",
|
||||
18,
|
||||
6,
|
||||
"2026-08-02",
|
||||
"active",
|
||||
),
|
||||
];
|
||||
|
||||
const summerTitles = [
|
||||
"夏天轻松管理身材的小习惯",
|
||||
"一周清爽饮食记录",
|
||||
"通勤女生的轻负担早餐",
|
||||
"周末宅家也要好好吃饭",
|
||||
"最近让我状态变好的三件事",
|
||||
"忙碌上班族的饮食搭配",
|
||||
"清爽一夏的简单生活方式",
|
||||
"我的夏日冰箱常备清单",
|
||||
"下班后的低成本幸福感",
|
||||
"在家也能完成的状态管理",
|
||||
"夏日办公室好物分享",
|
||||
"高温天的清爽仪式感",
|
||||
"一人食也可以很认真",
|
||||
"近期值得回购的小东西",
|
||||
"我的轻盈生活观察",
|
||||
"夏天拒绝疲惫感",
|
||||
"简单好坚持的日常习惯",
|
||||
"最近的通勤包里有什么",
|
||||
"不费力的夏日松弛感",
|
||||
"周末恢复能量的小计划",
|
||||
"高效生活的三个小改变",
|
||||
"一个人的清爽晚餐",
|
||||
"办公室里的续航秘诀",
|
||||
"生活需要一点轻盈感",
|
||||
"最近在坚持的健康习惯",
|
||||
"从早餐开始认真生活",
|
||||
"我的低负担下午茶",
|
||||
"夏日宅家幸福清单",
|
||||
"忙碌生活中的小确幸",
|
||||
"值得记录的轻盈一天",
|
||||
];
|
||||
const livingTitles = Array.from(
|
||||
{ length: 18 },
|
||||
(_, index) => `理想生活家的空间灵感 ${String(index + 1).padStart(2, "0")}`,
|
||||
);
|
||||
|
||||
summerTitles.forEach((title, index) => {
|
||||
batch.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO contents (id, task_id, title, body, status)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
`content-s-${index + 1}`,
|
||||
"task-summer",
|
||||
title,
|
||||
"来自飞书内容表的完整笔记正文与素材说明。",
|
||||
index < 9 ? "allocated" : "available",
|
||||
),
|
||||
);
|
||||
});
|
||||
livingTitles.forEach((title, index) => {
|
||||
batch.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO contents (id, task_id, title, body, status)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
`content-l-${index + 1}`,
|
||||
"task-living",
|
||||
title,
|
||||
"来自飞书内容表的完整笔记正文与素材说明。",
|
||||
index < 6 ? "allocated" : "available",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const accountSeeds = [
|
||||
["account-01", "xhs-8af3", "小满的轻生活", "上海", 12800, 4, 4360],
|
||||
["account-02", "xhs-2bd8", "橘子汽水日记", "杭州", 8600, 3, 2980],
|
||||
["account-03", "xhs-7ca1", "木木在成长", "广东", 21400, 2, 7620],
|
||||
["account-04", "xhs-91ee", "一颗软糖", "江苏", 5200, 2, 1850],
|
||||
];
|
||||
accountSeeds.forEach(([id, uid, nickname, ip, followers, posts, avg]) => {
|
||||
batch.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO accounts
|
||||
(id, platform, platform_uid, nickname, profile_url, ip_location, followers, post_count, avg_views)
|
||||
VALUES (?, '小红书', ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
id,
|
||||
uid,
|
||||
nickname,
|
||||
`https://www.xiaohongshu.com/user/profile/${uid}`,
|
||||
ip,
|
||||
followers,
|
||||
posts,
|
||||
avg,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const distributionSeeds = [
|
||||
[
|
||||
"dist-01",
|
||||
"content-s-1",
|
||||
"partner-linlin",
|
||||
"account-01",
|
||||
"https://www.xiaohongshu.com/explore/demo01",
|
||||
"complete",
|
||||
118,
|
||||
24,
|
||||
43,
|
||||
168,
|
||||
31,
|
||||
61,
|
||||
232,
|
||||
38,
|
||||
86,
|
||||
18420,
|
||||
9430,
|
||||
"recognized",
|
||||
],
|
||||
[
|
||||
"dist-02",
|
||||
"content-s-2",
|
||||
"partner-muzi",
|
||||
"account-02",
|
||||
"https://www.xiaohongshu.com/explore/demo02",
|
||||
"collecting",
|
||||
76,
|
||||
12,
|
||||
28,
|
||||
103,
|
||||
17,
|
||||
39,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"none",
|
||||
],
|
||||
[
|
||||
"dist-03",
|
||||
"content-s-3",
|
||||
"partner-linlin",
|
||||
"account-03",
|
||||
"https://www.xiaohongshu.com/explore/demo03",
|
||||
"published",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"none",
|
||||
],
|
||||
[
|
||||
"dist-04",
|
||||
"content-l-1",
|
||||
"partner-xiaolu",
|
||||
"account-04",
|
||||
"https://www.xiaohongshu.com/explore/demo04",
|
||||
"collecting",
|
||||
42,
|
||||
8,
|
||||
16,
|
||||
69,
|
||||
12,
|
||||
24,
|
||||
91,
|
||||
17,
|
||||
35,
|
||||
null,
|
||||
null,
|
||||
"uploaded",
|
||||
],
|
||||
];
|
||||
|
||||
distributionSeeds.forEach((seed, index) => {
|
||||
const [
|
||||
id,
|
||||
contentId,
|
||||
partnerId,
|
||||
accountId,
|
||||
publishUrl,
|
||||
status,
|
||||
d2Likes,
|
||||
d2Comments,
|
||||
d2Collects,
|
||||
d5Likes,
|
||||
d5Comments,
|
||||
d5Collects,
|
||||
d7Likes,
|
||||
d7Comments,
|
||||
d7Collects,
|
||||
exposure,
|
||||
views,
|
||||
ocrStatus,
|
||||
] = seed;
|
||||
batch.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO distributions (
|
||||
id, task_id, content_id, partner_id, account_id, publish_url,
|
||||
publish_time, status, claimed_at, ocr_status, exposure, views,
|
||||
d2_likes, d2_comments, d2_collects,
|
||||
d5_likes, d5_comments, d5_collects,
|
||||
d7_likes, d7_comments, d7_collects
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
id,
|
||||
index < 3 ? "task-summer" : "task-living",
|
||||
contentId,
|
||||
partnerId,
|
||||
accountId,
|
||||
publishUrl,
|
||||
`2026-07-${String(22 + index).padStart(2, "0")} 10:30:00`,
|
||||
status,
|
||||
`2026-07-${String(20 + index).padStart(2, "0")} 09:00:00`,
|
||||
ocrStatus,
|
||||
exposure,
|
||||
views,
|
||||
d2Likes,
|
||||
d2Comments,
|
||||
d2Collects,
|
||||
d5Likes,
|
||||
d5Comments,
|
||||
d5Collects,
|
||||
d7Likes,
|
||||
d7Comments,
|
||||
d7Collects,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
for (let index = 4; index < 9; index += 1) {
|
||||
batch.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO distributions
|
||||
(id, task_id, content_id, partner_id, status, claimed_at)
|
||||
VALUES (?, 'task-summer', ?, 'partner-linlin', 'claimed', ?)`,
|
||||
)
|
||||
.bind(
|
||||
`dist-pending-${index}`,
|
||||
`content-s-${index + 1}`,
|
||||
`2026-07-${String(24 + (index % 2)).padStart(2, "0")} 11:00:00`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await db.batch(batch);
|
||||
}
|
||||
|
||||
export async function getDashboardData() {
|
||||
const db = getRawDb();
|
||||
const [partnersResult, tasksResult, accountsResult, distributionsResult] =
|
||||
await Promise.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 accounts ORDER BY last_seen_at DESC").all(),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT
|
||||
d.*,
|
||||
c.title AS content_title,
|
||||
p.name AS partner_name,
|
||||
a.nickname AS account_nickname,
|
||||
a.platform AS account_platform,
|
||||
t.name AS task_name,
|
||||
t.brand AS task_brand,
|
||||
t.due_at AS due_at
|
||||
FROM distributions d
|
||||
JOIN contents c ON c.id = d.content_id
|
||||
JOIN partners p ON p.id = d.partner_id
|
||||
JOIN tasks t ON t.id = d.task_id
|
||||
LEFT JOIN accounts a ON a.id = d.account_id
|
||||
ORDER BY d.updated_at DESC, d.claimed_at DESC`,
|
||||
)
|
||||
.all(),
|
||||
]);
|
||||
|
||||
return {
|
||||
partners: partnersResult.results as D1ResultRow[],
|
||||
tasks: tasksResult.results as D1ResultRow[],
|
||||
accounts: accountsResult.results as D1ResultRow[],
|
||||
distributions: distributionsResult.results as D1ResultRow[],
|
||||
portal_url:
|
||||
(env as unknown as { KOC_PORTAL_URL?: string }).KOC_PORTAL_URL ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function uid(prefix: string) {
|
||||
return `${prefix}-${crypto.randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
export function hashText(value: string) {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return Math.abs(hash >>> 0).toString(36);
|
||||
}
|
||||
41
lib/partner-cors.ts
Normal file
41
lib/partner-cors.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
|
||||
function allowedOrigin(request: Request) {
|
||||
const origin = request.headers.get("origin");
|
||||
if (!origin) return null;
|
||||
const portalOrigin = String(
|
||||
(env as unknown as { KOC_PORTAL_URL?: string }).KOC_PORTAL_URL ?? "",
|
||||
).replace(/\/$/, "");
|
||||
return origin === portalOrigin || origin === "http://localhost:3000"
|
||||
? origin
|
||||
: null;
|
||||
}
|
||||
|
||||
export function withPartnerCors(request: Request, response: Response) {
|
||||
const origin = allowedOrigin(request);
|
||||
if (origin) {
|
||||
response.headers.set("Access-Control-Allow-Origin", origin);
|
||||
response.headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
response.headers.set(
|
||||
"Access-Control-Allow-Headers",
|
||||
[
|
||||
"Content-Type",
|
||||
"X-KOC-Task",
|
||||
"X-KOC-Claim",
|
||||
"X-KOC-Delegation",
|
||||
"X-KOC-Distribution",
|
||||
"X-KOC-File-Name",
|
||||
"X-KOC-Upload-Kind",
|
||||
"X-KOC-OCR-Exposure",
|
||||
"X-KOC-OCR-Views",
|
||||
"X-KOC-OCR-Status",
|
||||
].join(", "),
|
||||
);
|
||||
response.headers.append("Vary", "Origin");
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export function partnerOptions(request: Request) {
|
||||
return withPartnerCors(request, new Response(null, { status: 204 }));
|
||||
}
|
||||
29
lib/partner-utils.ts
Normal file
29
lib/partner-utils.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { hashText } from "./mvp-db";
|
||||
import {
|
||||
extractXhsPublishUrl,
|
||||
safeHttpUrl,
|
||||
} from "./publish-url";
|
||||
|
||||
export { extractXhsPublishUrl } from "./publish-url";
|
||||
|
||||
export function accountFromPublishLink(input: string, nickname: string) {
|
||||
const url = safeHttpUrl(extractXhsPublishUrl(input));
|
||||
if (!url) return null;
|
||||
const platform =
|
||||
url.hostname.includes("xiaohongshu") || url.hostname.includes("xhslink")
|
||||
? "小红书"
|
||||
: "其他平台";
|
||||
const noteId =
|
||||
url.pathname.match(
|
||||
/\/(?:discovery\/item|explore)\/([A-Za-z0-9_-]{8,80})/,
|
||||
)?.[1] ?? "";
|
||||
const platformUid = `pending-${hashText(
|
||||
noteId || `${url.origin}${url.pathname}`,
|
||||
)}`;
|
||||
return {
|
||||
platform,
|
||||
platformUid,
|
||||
nickname,
|
||||
profileUrl: "",
|
||||
};
|
||||
}
|
||||
27
lib/publish-url.ts
Normal file
27
lib/publish-url.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export function safeHttpUrl(input: string) {
|
||||
try {
|
||||
const url = new URL(input);
|
||||
if (!["http:", "https:"].includes(url.protocol)) return null;
|
||||
return url;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractXhsPublishUrl(input: string) {
|
||||
const candidates =
|
||||
input.match(/https?:\/\/[^\s<>"',。!?;:、【】()]+/gi) ?? [];
|
||||
for (const candidate of candidates) {
|
||||
const cleaned = candidate.replace(/[.,!?;:~)\]}]+$/g, "");
|
||||
const url = safeHttpUrl(cleaned);
|
||||
if (!url) continue;
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const isXhs =
|
||||
hostname === "xiaohongshu.com" ||
|
||||
hostname.endsWith(".xiaohongshu.com") ||
|
||||
hostname === "xhslink.cn" ||
|
||||
hostname.endsWith(".xhslink.cn");
|
||||
if (isXhs) return url.toString();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
Reference in New Issue
Block a user