diff --git a/src/background/index.ts b/src/background/index.ts index edd921c..3b7d636 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -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(); +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({ @@ -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) { diff --git a/src/background/services/api.ts b/src/background/services/api.ts index cd5c485..9684cd3 100644 --- a/src/background/services/api.ts +++ b/src/background/services/api.ts @@ -3,6 +3,10 @@ import { getPlatformInfos } from "~sync/common"; const storage = new Storage({ area: "local" }); +// 已处理过的 NEW_TASK URL(内存去重,防止同一任务多次被 ping 返回重复打开 tab) +const _handledTaskUrls = new Set(); +const _HANDLED_URLS_MAX = 500; + const host = process.env.NODE_ENV === "development" ? "http://localhost:3000" : "https://multipost.app"; export const ping = async (withPlatforms = false) => { @@ -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); } diff --git a/src/sync/dynamic/rednote.ts b/src/sync/dynamic/rednote.ts index 878cda6..8722d20 100644 --- a/src/sync/dynamic/rednote.ts +++ b/src/sync/dynamic/rednote.ts @@ -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) { @@ -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("没有成功添加任何文件"); @@ -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; @@ -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"; } } diff --git a/src/sync/dynamic/toutiao.ts b/src/sync/dynamic/toutiao.ts index 46ffbe5..839f3b0 100644 --- a/src/sync/dynamic/toutiao.ts +++ b/src/sync/dynamic/toutiao.ts @@ -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); } @@ -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)); } } } @@ -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) {