Initial commit: KOC LOOP platform

This commit is contained in:
巫凤萍
2026-07-30 12:06:41 +08:00
commit d2cde2c9b2
98 changed files with 44076 additions and 0 deletions

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

View 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

File diff suppressed because it is too large Load Diff

721
lib/mvp-db.ts Normal file
View 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
View 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
View 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
View 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 "";
}