Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,48 @@ chrome.tabs.onRemoved.addListener((tabId) => {
// Message Handler || 消息处理器 || START
let currentSyncData: SyncData | null = null;
let currentPublishPopup: chrome.windows.Window | null = null;

// 防止同一发布请求短时间内被多次触发(如 NEW_TASK 被 multipost.app 网页或 React StrictMode 重复 dispatch)
const _recentPublishKeys = new Map<string, number>();
const _PUBLISH_DEDUP_WINDOW_MS = 60_000;

function _publishKey(data: SyncData): string {
try {
const platforms = (data.platforms || [])
.map((p) => p?.name || "")
.sort()
.join(",");
const inner: any = data.data || {};
const content = (inner.content || inner.title || "").slice(0, 256);
const imgs = Array.isArray(inner.images) ? inner.images.length : 0;
return `${platforms}|${imgs}|${content}`;
} catch {
return JSON.stringify(data).slice(0, 512);
}
}

function _shouldDedupePublish(data: SyncData): boolean {
const key = _publishKey(data);
const now = Date.now();
// 清理过期 key
for (const [k, t] of _recentPublishKeys.entries()) {
if (now - t > _PUBLISH_DEDUP_WINDOW_MS) _recentPublishKeys.delete(k);
}
if (_recentPublishKeys.has(key)) return true;
_recentPublishKeys.set(key, now);
return false;
}

const defaultMessageHandler = (request, _sender, sendResponse) => {
if (request.action === "MULTIPOST_EXTENSION_CHECK_SERVICE_STATUS") {
sendResponse({ extensionId: chrome.runtime.id });
}
if (request.action === "MULTIPOST_EXTENSION_PUBLISH") {
const data = request.data as SyncData;
if (_shouldDedupePublish(data)) {
console.log("[MultiPost] skip duplicate PUBLISH request");
return;
}
currentSyncData = data;
(async () => {
currentPublishPopup = await chrome.windows.create({
Expand Down Expand Up @@ -102,6 +138,48 @@ const defaultMessageHandler = (request, _sender, sendResponse) => {
if (request.action === "MULTIPOST_EXTENSION_PUBLISH_REQUEST_SYNC_DATA") {
sendResponse({ syncData: currentSyncData });
}
if (request.action === "MULTIPOST_FETCH_IMAGE") {
const url = request.url as string;
(async () => {
try {
const r = await fetch(url);
if (!r.ok) {
sendResponse({ ok: false, error: `HTTP ${r.status}` });
return;
}
const buf = await r.arrayBuffer();
let bin = "";
const bytes = new Uint8Array(buf);
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)) as unknown as number[]);
}
const b64 = btoa(bin);
sendResponse({ ok: true, base64: b64, contentType: r.headers.get("content-type") || "" });
} catch (e) {
sendResponse({ ok: false, error: String(e) });
}
})();
return true;
}
if (request.action === "MULTIPOST_REPORT_LINK") {
const platform = request.platform as string;
const link = request.link as string;
(async () => {
try {
const r = await fetch("http://127.0.0.1:8765/report", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ platform, link }),
});
const txt = await r.text();
sendResponse({ ok: r.ok, status: r.status, body: txt });
} catch (e) {
sendResponse({ ok: false, error: String(e) });
}
})();
return true;
}
if (request.action === "MULTIPOST_EXTENSION_PUBLISH_NOW") {
const data = request.data as SyncData;
if (Array.isArray(data.platforms) && data.platforms.length > 0) {
Expand Down
17 changes: 16 additions & 1 deletion src/background/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { getPlatformInfos } from "~sync/common";

const storage = new Storage({ area: "local" });

// 已处理过的 NEW_TASK URL(内存去重,防止同一任务多次被 ping 返回重复打开 tab)
const _handledTaskUrls = new Set<string>();
const _HANDLED_URLS_MAX = 500;

const host = process.env.NODE_ENV === "development" ? "http://localhost:3000" : "https://multipost.app";

export const ping = async (withPlatforms = false) => {
Expand Down Expand Up @@ -44,7 +48,18 @@ export const ping = async (withPlatforms = false) => {
} else if (!body.success && body.error === "CLIENT_NOT_FOUND") {
await storage.remove("extensionClientId");
} else if (body.success && body.data.action === "NEW_TASK") {
chrome.tabs.create({ url: body.data.url });
const taskUrl = body.data.url as string;
if (_handledTaskUrls.has(taskUrl)) {
console.log("[MultiPost] skip duplicate NEW_TASK:", taskUrl);
} else {
_handledTaskUrls.add(taskUrl);
if (_handledTaskUrls.size > _HANDLED_URLS_MAX) {
const iter = _handledTaskUrls.values();
const first = iter.next().value;
if (first) _handledTaskUrls.delete(first);
}
chrome.tabs.create({ url: taskUrl });
}
} else if (body.success && body.data.action === "NEW_CLIENT") {
await storage.set("extensionClientId", body.data.clientId);
}
Expand Down
65 changes: 58 additions & 7 deletions src/sync/dynamic/rednote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,22 @@ export async function DynamicRednote(data: SyncData) {

for (const fileInfo of images) {
try {
const response = await fetch(fileInfo.url);
if (!response.ok) {
throw new Error(`HTTP 错误! 状态: ${response.status}`);
let blob: Blob;
try {
const response = await fetch(fileInfo.url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
blob = await response.blob();
} catch (err) {
console.warn("[rednote] direct fetch failed, fallback to background:", err);
const resp: any = await new Promise((resolve) => {
chrome.runtime.sendMessage({ action: "MULTIPOST_FETCH_IMAGE", url: fileInfo.url }, (r) => resolve(r));
});
if (!resp || !resp.ok) throw new Error(`background fetch failed: ${resp?.error || "unknown"}`);
const bin = atob(resp.base64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
blob = new Blob([arr], { type: fileInfo.type });
}
const blob = await response.blob();
const file = new File([blob], fileInfo.name, { type: fileInfo.type });
dataTransfer.items.add(file);
} catch (error) {
Expand All @@ -59,7 +70,20 @@ export async function DynamicRednote(data: SyncData) {
if (dataTransfer.files.length > 0) {
fileInput.files = dataTransfer.files;
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 2000)); // 等待文件处理
// 轮询:等待页面出现与图片数量匹配的缩略图,最多 60s
const expectedCount = dataTransfer.files.length;
const uploadDeadline = Date.now() + 60000;
while (Date.now() < uploadDeadline) {
await new Promise((resolve) => setTimeout(resolve, 1000));
// 小红书上传后会渲染 .img-preview / .upload-img / img.preview-img 等节点
const thumbs = document.querySelectorAll(
".img-preview, .upload-img img, .preview-img, .upload-content img, .img-container img",
);
if (thumbs.length >= expectedCount) {
console.log(`[rednote] ${thumbs.length} 张图片已渲染缩略图`);
break;
}
}
console.log("文件上传操作完成");
} else {
console.error("没有成功添加任何文件");
Expand Down Expand Up @@ -88,7 +112,8 @@ export async function DynamicRednote(data: SyncData) {

// 上传文件
await uploadImages();
await new Promise((resolve) => setTimeout(resolve, 5000)); // 等待图片上传完成
// uploadImages 内部已轮询缩略图渲染;此处再缓 1s 让发布按钮变可点
await new Promise((resolve) => setTimeout(resolve, 1000));

// 填写标题
const titleInput = (await waitForElement('input[type="text"]')) as HTMLInputElement;
Expand Down Expand Up @@ -131,7 +156,33 @@ export async function DynamicRednote(data: SyncData) {

console.log("点击发布按钮");
publishButton.click();
await new Promise((resolve) => setTimeout(resolve, 10000));

// 小红书点击发布后 URL 会变化(可能是 /publish/success?... 或 /publish/update?id=...)
// 等任意 URL 变化即视为发布动作完成;最多等 30s
const startUrl = location.href;
const deadline = Date.now() + 30000;
let finalUrl: string | null = null;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 1000));
const cur = location.href;
if (cur !== startUrl) {
finalUrl = cur;
break;
}
}
const reportUrl = finalUrl || location.href;
try {
chrome.runtime.sendMessage({
action: "MULTIPOST_REPORT_LINK",
platform: "DYNAMIC_REDNOTE",
link: reportUrl,
});
console.log("[MultiPost/rednote] reported link:", reportUrl);
} catch (e) {
console.warn("[MultiPost/rednote] report failed:", e);
}
// 给 background 一点时间完成 fetch,再跳管理页
await new Promise((resolve) => setTimeout(resolve, 1500));
window.location.href = "https://creator.xiaohongshu.com/new/note-manager";
}
}
Expand Down
78 changes: 70 additions & 8 deletions src/sync/dynamic/toutiao.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,26 @@ export async function DynamicToutiao(data: SyncData) {
continue;
}

const response = await fetch(image.url);
const arrayBuffer = await response.arrayBuffer();
let arrayBuffer: ArrayBuffer | null = null;
try {
const direct = await fetch(image.url);
if (!direct.ok) throw new Error(`HTTP ${direct.status}`);
arrayBuffer = await direct.arrayBuffer();
} catch (e) {
console.warn("[MultiPost/toutiao] 直连 fetch 失败,回退 background 代取:", e);
const resp = await chrome.runtime.sendMessage({
action: "MULTIPOST_FETCH_IMAGE",
url: image.url,
});
if (!resp || !resp.ok || !resp.base64) {
console.error("[MultiPost/toutiao] background fetch 也失败:", resp);
continue;
}
const bin = atob(resp.base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
arrayBuffer = bytes.buffer;
}
const file = new File([arrayBuffer], image.name, { type: image.type });
dataTransfer.items.add(file);
}
Expand All @@ -88,15 +106,35 @@ export async function DynamicToutiao(data: SyncData) {
fileInput.dispatchEvent(new Event("input", { bubbles: true }));
}

// 等待上传完成
await new Promise((resolve) => setTimeout(resolve, 5000));
// 等待上传完成:轮询 confirm 按钮变为可点击(未 disabled),最多 60s
const confirmSelector = 'button[data-e2e="imageUploadConfirm-btn"]';
let confirmButton: HTMLButtonElement | null = null;
const uploadDeadline = Date.now() + 60000;
while (Date.now() < uploadDeadline) {
const btn = document.querySelector(confirmSelector) as HTMLButtonElement | null;
if (btn && !btn.disabled && !btn.getAttribute("disabled")) {
confirmButton = btn;
break;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
if (!confirmButton) {
console.warn("[MultiPost/toutiao] 图片上传等待超时,未检测到可点击的确认按钮");
return;
}

// 点击确认按钮
const confirmButton = document.querySelector('button[data-e2e="imageUploadConfirm-btn"]');
if (confirmButton) {
confirmButton.dispatchEvent(new Event("click", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 2000));
confirmButton.dispatchEvent(new Event("click", { bubbles: true }));

// 等确认弹窗消失 + 编辑器里出现图片缩略图,最多 30s
const insertDeadline = Date.now() + 30000;
while (Date.now() < insertDeadline) {
const dialogGone = !document.querySelector(confirmSelector);
const thumb = document.querySelector(".image-remove-btn, .byte-upload-image, .syl-image");
if (dialogGone && thumb) break;
await new Promise((resolve) => setTimeout(resolve, 500));
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}
Expand All @@ -106,6 +144,30 @@ export async function DynamicToutiao(data: SyncData) {
if (publishButton) {
if (data.isAutoPublish) {
publishButton.dispatchEvent(new Event("click", { bubbles: true }));

const startUrl = location.href;
const deadline = Date.now() + 45000;
let finalUrl: string | null = null;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 1000));
const cur = location.href;
// 头条发布成功后跳转到 /publish?edit_id=xxx(可再次编辑)
if (cur !== startUrl && cur.includes("edit_id=")) {
finalUrl = cur;
break;
}
}
const reportUrl = finalUrl || location.href;
try {
chrome.runtime.sendMessage({
action: "MULTIPOST_REPORT_LINK",
platform: "DYNAMIC_TOUTIAO",
link: reportUrl,
});
console.log("[MultiPost/toutiao] reported link:", reportUrl);
} catch (e) {
console.warn("[MultiPost/toutiao] report failed:", e);
}
}
}
} catch (error) {
Expand Down