xiaohongshufabu/extension/content_scripts/qianfan_publish.js
2026-07-22 10:55:17 +08:00

1041 lines
45 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 千帆发布页 DOM 自动化脚本 v2
* 注入到 https://ark.xiaohongshu.com/* 页面
*
* 选择器基于实际千帆页面 DOM 结构2026-07-18 实测确认)。
* 千帆发布页是多步表单:先上传图片,再填写内容并发布。
*/
// ==================== 选择器配置(集中管理,便于修改)====================
const SELECTORS = {
// --- 上传步骤 ---
// 图片文件上传 inputmultiple, accept=.jpg,.jpeg,.png,.webp
imageUploadInput: 'input.upload-input[type="file"][multiple]',
// 上传按钮
imageUploadButton: 'button.upload-button',
// 上传区域(用于拖拽检测)
imageDropZone: '.upload-wrapper',
// 上传进度/图片列表容器
imagePreviewList: '.upload-list, .image-list, [class*="preview"]',
// --- 内容编辑步骤(上传图片后出现)---
// 正文编辑器contenteditable div
contentEditor: '[contenteditable="true"]',
// 备选Quill / ProseMirror 编辑器
contentEditorAlt: '.ql-editor, .ProseMirror, [class*="editor"] [contenteditable]',
// 标题输入(如有独立标题输入框)
titleInput: 'input[placeholder*="标题"], input[class*="title"]',
// 话题输入框
topicInput: 'input[placeholder*="话题"], input[placeholder*="标签"], input[class*="topic"]',
// 话题建议下拉项
topicSuggestion: '.topic-suggestion, [class*="topic-list"] > div, .suggest-item, .d-dropdown-item',
// --- 选品步骤 ---
productSearchInput: '#ark-app-note-mount-container > div > div > div > div.main-item-list-wrap > div.list-header > div.list-header-right > div > div > input',
// 搜索结果列表容器("去发笔记" 链接在其中,按文本匹配而非固定 nth-child避免结果位置变化导致失效
productListContainer: '#ark-app-note-mount-container .main-item-list-wrap .list-content',
// --- 发布页实测确认的精确选择器2026-07-20---
// "上传图文" tab
imageTextTab: '#ark > div.outarea.upload-c > div > div > div.header > div:nth-child(2) > span',
// 标题输入框
titleInputExact: '#ark > div.outarea.publish-c > div > div > div > div.body > div.content > div.input.titleInput > div.d-input-wrapper.d-inline-block.c-input_inner > div > input',
// 正文编辑器Quill容器 id 为 quillEditor
contentEditorExact: '#quillEditor > div',
// 发布按钮
publishButtonExact: '#ark > div.outarea.publish-c > div > div > div > div.submit > div > button.publishBtn',
// 发布成功页的"继续发布/返回"按钮
publishSuccessContinueButton: '#ark-app-note-mount-container > div > div > div.d-result > div > div.d-result-extra > button.d-button.d-button-default.d-button-with-content.--color-static.bold.--color-bg-primary.--color-white',
// --- 页面状态 ---
// 主发布容器
mainContainer: '.note-publish-wrap',
// 上传步骤容器
uploadStep: '.upload-content',
// 内容编辑步骤容器
editStep: '.content-editor, [class*="edit-content"], [class*="note-content"]',
};
// ==================== 从 storage 加载自定义选择器 ====================
async function loadCustomSelectors() {
return new Promise((resolve) => {
chrome.storage.local.get(["config"], (data) => {
const cfg = data.config || {};
const custom = {};
if (cfg.selSearchInput) custom.productSearchInput = cfg.selSearchInput;
if (cfg.selUploadArea) custom.imageUploadInput = cfg.selUploadArea;
if (cfg.selEditor) custom.contentEditor = cfg.selEditor;
if (cfg.selTopicInput) custom.topicInput = cfg.selTopicInput;
if (cfg.selSubmitBtn) custom.publishButton = cfg.selSubmitBtn;
resolve(custom);
});
});
}
// ==================== 工具函数 ====================
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// 等待元素出现
function waitForElement(selector, timeout = 10000) {
return new Promise((resolve, reject) => {
const existing = document.querySelector(selector);
if (existing) return resolve(existing);
const observer = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el) {
observer.disconnect();
resolve(el);
}
});
observer.observe(document.body, {childList: true, subtree: true});
setTimeout(() => {
observer.disconnect();
reject(new Error(`等待元素超时: ${selector}`));
}, timeout);
});
}
// 尝试多个选择器,返回第一个匹配的
function queryFirst(selectors) {
for (const sel of selectors) {
try {
const el = document.querySelector(sel);
if (el) return el;
} catch (e) { /* 忽略无效选择器 */
}
}
return null;
}
function getEditableElement(element) {
if (!element) return null;
if (element.tagName === "INPUT" || element.tagName === "TEXTAREA" || element.contentEditable === "true") return element;
return element.querySelector('input, textarea, [contenteditable="true"]') || element;
}
// 模拟人类输入(触发 React/Vue 受控组件的事件)
function simulateInput(element, value) {
element = getEditableElement(element);
element.focus();
element.dispatchEvent(new Event("focus", {bubbles: true}));
if (element.tagName === "INPUT" || element.tagName === "TEXTAREA") {
element.value = "";
element.dispatchEvent(new Event("input", {bubbles: true}));
const proto = element.tagName === "TEXTAREA"
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
const nativeSetter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
if (nativeSetter) {
nativeSetter.call(element, value);
} else {
element.value = value;
}
element.dispatchEvent(new Event("input", {bubbles: true}));
element.dispatchEvent(new Event("change", {bubbles: true}));
} else if (element.contentEditable === "true") {
// 方法1: 使用 execCommand兼容性最好
element.innerHTML = "";
document.execCommand("selectAll", false, null);
document.execCommand("delete", false, null);
// 将内容按段落分割,逐段写入
const paragraphs = value.split(/\n/);
for (let i = 0; i < paragraphs.length; i++) {
if (i > 0) {
document.execCommand("insertParagraph", false, null);
}
if (paragraphs[i]) {
document.execCommand("insertText", false, paragraphs[i]);
}
}
// 触发事件同步框架状态
element.dispatchEvent(new InputEvent("input", {bubbles: true, inputType: "insertText"}));
element.dispatchEvent(new Event("change", {bubbles: true}));
// 方法2备选: 如果 execCommand 不生效,使用 clipboard paste
if (!element.textContent.trim() && value.trim()) {
const clipboardData = new DataTransfer();
clipboardData.setData("text/plain", value);
const pasteEvent = new ClipboardEvent("paste", {
bubbles: true,
cancelable: true,
clipboardData: clipboardData,
});
element.dispatchEvent(pasteEvent);
}
}
element.dispatchEvent(new Event("blur", {bubbles: true}));
}
// 获取配置
async function getConfig() {
return new Promise((resolve) => {
chrome.storage.local.get(["config"], (data) => {
resolve(data.config || {serverUrl: "http://localhost:3000"});
});
});
}
// 通过 background.js 代理获取图片(绕过 CORS 和混合内容限制)
function fetchImageViaBackground(imageUrl) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({type: "FETCH_IMAGE", url: imageUrl}, (res) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (res && res.ok) {
resolve(res.dataUrl);
} else {
reject(new Error(res && res.error ? res.error : "图片获取失败"));
}
});
});
}
// 模拟文件上传(通过 DataTransfer 注入 file input
async function uploadFilesToFileInput(fileInput, imageUrls) {
const files = [];
for (const url of imageUrls) {
try {
console.log("[千帆助手] 正在获取图片:", url);
const dataUrl = await fetchImageViaBackground(url);
const imgResp = await fetch(dataUrl);
const blob = await imgResp.blob();
const filename = url.split("/").pop() || `image_${Date.now()}.jpg`;
files.push(new File([blob], filename, {type: blob.type || "image/jpeg"}));
} catch (e) {
console.error("[千帆助手] 图片下载失败:", url, e);
}
}
if (files.length === 0) throw new Error("没有可用的图片文件");
const dataTransfer = new DataTransfer();
files.forEach((f) => dataTransfer.items.add(f));
fileInput.files = dataTransfer.files;
fileInput.dispatchEvent(new Event("change", {bubbles: true}));
fileInput.dispatchEvent(new Event("input", {bubbles: true}));
return files.length;
}
// 模拟拖拽上传(备选方案)
async function simulateDropUpload(dropZone, imageUrls) {
const files = [];
for (const url of imageUrls) {
try {
console.log("[千帆助手] 正在获取图片:", url);
const dataUrl = await fetchImageViaBackground(url);
const imgResp = await fetch(dataUrl);
const blob = await imgResp.blob();
files.push(new File([blob], url.split("/").pop() || `image_${Date.now()}.jpg`, {type: blob.type || "image/jpeg"}));
} catch (e) {
console.error("[千帆助手] 图片下载失败:", url, e);
}
}
if (files.length === 0) throw new Error("没有可用的图片文件");
const dataTransfer = new DataTransfer();
files.forEach((f) => dataTransfer.items.add(f));
dropZone.dispatchEvent(new DragEvent("dragenter", {bubbles: true, dataTransfer}));
dropZone.dispatchEvent(new DragEvent("dragover", {bubbles: true, dataTransfer}));
dropZone.dispatchEvent(new DragEvent("drop", {bubbles: true, dataTransfer}));
return files.length;
}
// 逐字符向 Quill/富文本编辑器插入文本CJK 友好)
// 合成 keydown 不携带中文,故用 execCommand insertText 逐字插入触发 Quill text-change
async function typeIntoQuill(editor, text) {
editor.focus();
editor.click();
for (const ch of text) {
if (ch === "\n") {
editor.dispatchEvent(new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
keyCode: 13,
bubbles: true
}));
document.execCommand("insertParagraph", false, null);
editor.dispatchEvent(new KeyboardEvent("keyup", {key: "Enter", code: "Enter", keyCode: 13, bubbles: true}));
} else {
editor.dispatchEvent(new KeyboardEvent("keydown", {key: ch, bubbles: true}));
editor.dispatchEvent(new KeyboardEvent("keypress", {key: ch, bubbles: true}));
document.execCommand("insertText", false, ch);
editor.dispatchEvent(new InputEvent("input", {bubbles: true, inputType: "insertText", data: ch}));
editor.dispatchEvent(new KeyboardEvent("keyup", {key: ch, bubbles: true}));
}
}
editor.dispatchEvent(new Event("change", {bubbles: true}));
editor.dispatchEvent(new Event("blur", {bubbles: true}));
}
// 在 Quill 编辑器触发 # mention插入 #+话题名 → 等 mention 下拉 → 选第一项
// 返回 true 表示 mention 选中false 表示下拉未出现,#话题名已作为纯文本留在正文里
async function clickQuillMention(editor, topic) {
editor.focus();
const trigger = (ch) => {
editor.dispatchEvent(new KeyboardEvent("keydown", {key: ch, bubbles: true}));
document.execCommand("insertText", false, ch);
editor.dispatchEvent(new InputEvent("input", {bubbles: true, inputType: "insertText", data: ch}));
editor.dispatchEvent(new KeyboardEvent("keyup", {key: ch, bubbles: true}));
};
trigger("#");
for (const ch of topic) trigger(ch);
let list;
try {
list = await waitForElement("div.ql-mention-list-container", 5000);
} catch (e) {
return false;
}
const firstItem = list.querySelector(".ql-mention-list li, .ql-mention-item, .d-dropdown-item, li");
if (!firstItem) return false;
firstItem.click();
return true;
}
// 按文本查找按钮(精确匹配优先,避免误匹配)
function findButtonByText(text) {
const buttons = document.querySelectorAll("button");
// First pass: exact match
for (const btn of buttons) {
const btnText = btn.textContent.trim();
if (btnText === text && !btn.disabled) {
return btn;
}
}
// Second pass: contains match (exclude buttons with different primary text)
const excludeWords = {"发布": ["存草稿"]};
for (const btn of buttons) {
const btnText = btn.textContent.trim();
if (btnText.includes(text) && !btn.disabled) {
const excludes = excludeWords[text] || [];
if (!excludes.some(ex => btnText.includes(ex))) {
return btn;
}
}
}
return null;
}
// Wait for page to transition away from item-select to publish page
function waitForPublishPage(timeoutMs = 20000) {
return new Promise((resolve, reject) => {
const startTime = Date.now();
function check() {
// Check if we're now on the publish page (not item-select)
if (isPublishPage()) {
return resolve(true);
}
// Also check if URL changed away from itemSelect
if (!window.location.href.includes("itemSelect") &&
!window.location.href.includes("publish-item-select")) {
return resolve(true);
}
if (Date.now() - startTime > timeoutMs) {
return reject(new Error("等待发布页超时,请确认已选择商品"));
}
setTimeout(check, 500);
}
check();
});
}
async function waitForPageReady(timeoutMs = 30000) {
if (document.readyState === "complete") return;
await new Promise((resolve) => {
const done = () => {
window.removeEventListener("load", done);
resolve();
};
window.addEventListener("load", done, {once: true});
setTimeout(done, timeoutMs);
});
}
async function waitForAnyElement(selectors, timeoutMs = 15000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const el = queryFirst(selectors);
if (el) return el;
await sleep(Math.floor(Math.random() * 2001) + 1000);
}
return null;
}
function clickElement(el) {
el.scrollIntoView?.({behavior: "smooth", block: "center", inline: "center"});
el.dispatchEvent(new MouseEvent("mouseover", {bubbles: true}));
el.dispatchEvent(new MouseEvent("mousedown", {bubbles: true, button: 0}));
el.dispatchEvent(new MouseEvent("mouseup", {bubbles: true, button: 0}));
el.dispatchEvent(new MouseEvent("click", {bubbles: true, button: 0}));
}
function findClickableByText(selector, text) {
return Array.from(document.querySelectorAll(selector)).find((el) => el.textContent.trim().includes(text));
}
// ==================== 核心发布流程 ====================
// Wait for publish result after clicking publish button
// 之前用 "!url.includes('publish')" 来判断"已经不在发布页=成功"
// 但发布成功页 URL 是 .../app-note/publish-success本身就带 "publish",这个判断从来没生效过,
// 导致每次都要等满 15 秒超时才"假设成功",而且从来没有真正处理过成功页。
function waitForPublishResult(timeoutMs = 30000) {
return new Promise((resolve) => {
const startTime = Date.now();
function check() {
if (isPublishSuccessPage()) {
return resolve({success: true, onSuccessPage: true});
}
const successToast = document.querySelector(
'.toast-success, [class*="success-toast"], [class*="message-success"], .el-message--success'
);
if (successToast) return resolve({success: true, onSuccessPage: false});
const errorToast = document.querySelector(
'.toast-error, [class*="error-toast"], [class*="message-error"], .el-message--error'
);
if (errorToast) return resolve({success: false, error: errorToast.textContent.trim() || "发布失败"});
if (Date.now() - startTime > timeoutMs) {
console.warn("[千帆助手] 发布结果检测超时,假设成功");
return resolve({success: true, onSuccessPage: false, timedOut: true});
}
setTimeout(check, 500);
}
check();
});
}
// 在"笔记发布成功"页点击继续按钮,并等待页面跳回选品/搜索页,供下一条笔记继续发布
async function continueFromPublishSuccessPage(timeoutMs = 15000) {
console.log("[千帆助手] 已到达发布成功页,准备点击继续按钮");
// 先等结果区容器渲染出来再去找按钮避免页面刚跳转、DOM 还没渲染完就查询
await waitForAnyElement(["#ark-app-note-mount-container .d-result", "#ark-app-note-mount-container"], 8000);
await sleep(Math.floor(Math.random() * 2001) + 1000);
let continueBtn = await waitForAnyElement([
SELECTORS.publishSuccessContinueButton,
"#ark-app-note-mount-container .d-result-extra button",
".d-result-extra button",
".d-result button",
], 12000);
if (!continueBtn) {
// 再按文字兜底找一次(比如"继续发布"、"再发一篇"、"返回"之类的文案)
continueBtn = findClickableByText("button, a", "继续")
|| findClickableByText("button, a", "再发")
|| findClickableByText("button, a", "返回")
|| findClickableByText("button, a", "下一");
}
if (!continueBtn) {
alertSelectorFailure("publish_success_continue", SELECTORS.publishSuccessContinueButton,
"continue button not found on publish-success page",
[SELECTORS.publishSuccessContinueButton, ".d-result-extra button", ".d-result button"]);
console.warn("[千帆助手] 未找到发布成功页的继续按钮,流程可能会停在此页");
return false;
}
clickElement(continueBtn);
console.log("[千帆助手] 已点击继续按钮,等待跳转到选品页");
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
if (isItemSelectPage() || !isPublishSuccessPage()) {
console.log("[千帆助手] 已跳回选品页,可继续下一条笔记");
return true;
}
await sleep(Math.floor(Math.random() * 2001) + 1000);
}
console.warn("[千帆助手] 点击继续按钮后,等待跳转超时,仍停留在发布成功页");
return false;
}
async function waitForImageUploadComplete(expectedCount, timeoutMs = 20000) {
const startTime = Date.now();
let lastLog = 0;
while (Date.now() - startTime < timeoutMs) {
const uploading = document.querySelector(
'.upload-loading, [class*="uploading"], [class*="upload-progress"], .el-loading, [class*="loading"]'
);
const previewCount = document.querySelectorAll(
'[class*="preview"] img, [class*="upload-list"] img, [class*="image-item"] img, [class*="upload"] img'
).length;
const errorEl = document.querySelector('[class*="upload-error"], [class*="upload-fail"]');
if (errorEl) {
console.warn("[千帆助手] 检测到图片上传失败标志:", errorEl.textContent.trim().slice(0, 50));
}
const elapsed = Date.now() - startTime;
if (elapsed - lastLog > 5000) {
console.log(`[千帆助手] 等待图片上传中... 已等待 ${Math.round(elapsed / 1000)}s预览图数量 ${previewCount}/${expectedCount},上传中标志: ${!!uploading}`);
lastLog = elapsed;
}
// 没有"上传中"标志,且预览图数量已经达到预期,认为上传完成
if (!uploading && previewCount >= expectedCount) {
console.log(`[千帆助手] 图片上传完成,预览图数量 ${previewCount}/${expectedCount}`);
await sleep(Math.floor(Math.random() * 2001) + 1000);
return true;
}
await sleep(Math.floor(Math.random() * 2001) + 1000);
}
console.warn("[千帆助手] 等待图片上传完成超时,继续往下走(可能图片实际还没传完)");
return false;
}
// 更可靠的逐字符输入:比一次性用 nativeSetter 塞入整个字符串更接近真实打字,
// 部分带防抖/搜索联想的输入框,一次性 setter 赋值容易被内部逻辑吞掉或截断
// (尤其是中文,如果组件依赖 IME composition 事件判断"输入是否结束")。
async function typeTextRobust(inputEl, text) {
const proto = inputEl.tagName === "TEXTAREA"
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
const nativeSetter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
inputEl.focus();
inputEl.dispatchEvent(new Event("focus", {bubbles: true}));
// 先清空
if (nativeSetter) nativeSetter.call(inputEl, ""); else inputEl.value = "";
inputEl.dispatchEvent(new InputEvent("input", {bubbles: true, inputType: "deleteContentBackward"}));
// 模拟中文输入法compositionstart → 逐字 compositionupdate → compositionend
inputEl.dispatchEvent(new CompositionEvent("compositionstart", {bubbles: true, data: ""}));
let current = "";
for (const ch of text) {
current += ch;
inputEl.dispatchEvent(new KeyboardEvent("keydown", {key: ch, bubbles: true}));
if (nativeSetter) nativeSetter.call(inputEl, current); else inputEl.value = current;
inputEl.dispatchEvent(new CompositionEvent("compositionupdate", {bubbles: true, data: current}));
inputEl.dispatchEvent(new InputEvent("input", {bubbles: true, inputType: "insertText", data: ch}));
inputEl.dispatchEvent(new KeyboardEvent("keyup", {key: ch, bubbles: true}));
}
inputEl.dispatchEvent(new CompositionEvent("compositionend", {bubbles: true, data: current}));
// compositionend 后很多框架会重新按最终值触发一次 input保险起见再补一次
if (nativeSetter) nativeSetter.call(inputEl, current); else inputEl.value = current;
inputEl.dispatchEvent(new InputEvent("input", {bubbles: true, inputType: "insertFromComposition", data: current}));
inputEl.dispatchEvent(new Event("change", {bubbles: true}));
return current;
}
// 在结果列表里找"标题包含 searchTerm"的商品卡片,再定位卡片内的"去发笔记"链接,
// 而不是不加区分地拿容器里第一个"去发笔记"链接——如果搜索没真正生效(列表还是默认
// 全部商品),那样每次点的都是排在最前面的默认商品,而不是搜索到的目标商品。
function findGoNoteLinkForProduct(container, searchTerm) {
if (!container) return null;
const candidateLinks = Array.from(container.querySelectorAll("a, button"))
.filter((el) => el.textContent.trim().includes("去发笔记"));
for (const link of candidateLinks) {
let node = link;
for (let depth = 0; depth < 8 && node; depth++, node = node.parentElement) {
if (node.textContent && node.textContent.includes(searchTerm)) {
return link;
}
}
}
return null;
}
async function executePublish(task) {
const note = task.note;
if (!note) throw new Error("任务中没有笔记数据");
console.log(`[千帆助手] 开始发布: ${note.title}`);
const customSelectors = await loadCustomSelectors();
const S = {...SELECTORS, ...customSelectors};
await waitForPageReady();
// --- 选品页:搜索商品并跳转 ---
if (isItemSelectPage()) {
// C1: 搜索词 = product_title 按 | 切第一段(与 Automa 实测一致)
const rawTitle = note.product_title || note.title || "";
const searchTerm = rawTitle.split("|")[0].trim();
if (!searchTerm) throw new Error("笔记缺少商品标题,无法在选品页搜索");
console.log("[千帆助手] STEP 1: 选品页搜索商品", {searchTerm, fullTitle: rawTitle});
const productSearchSelectors = [
S.productSearchInput,
"#ark-app-note-mount-container .main-item-list-wrap .list-header .list-header-right input",
"#ark-app-note-mount-container .main-item-list-wrap .list-header .list-header-right > div > div",
".main-item-list-wrap .list-header-right input",
".main-item-list-wrap .list-header-right [contenteditable='true']",
".pressing > .d-text",
"input.d-text",
"[class*='d-text']",
'input[placeholder*="商品"]',
'input[placeholder*="搜索"]',
];
const searchBox = await waitForAnyElement(productSearchSelectors, 15000);
if (!searchBox) {
alertSelectorFailure("search_input", S.productSearchInput, "product search box not found", productSearchSelectors);
throw new Error("未找到商品搜索框");
}
// 很多搜索框是"先点一下才展开出真正 <input>"的交互;如果点击前就把外层 div 当成
// 可编辑元素锁定住simulateInput 对一个既不是 input/textarea 也不是 contenteditable
// 的普通 div 什么都不会做——搜索词根本没输进去Enter 自然也无效。
// 所以这里先点击容器,等一下,再重新查找真正的可编辑元素。
clickElement(searchBox);
await sleep(Math.floor(Math.random() * 2001) + 1000);
let searchInput = getEditableElement(
queryFirst(productSearchSelectors) || searchBox
);
// 点击后再等一次,给组件时间渲染出真正的 input
if (!searchInput || (searchInput.tagName !== "INPUT" && searchInput.tagName !== "TEXTAREA" && searchInput.contentEditable !== "true")) {
await sleep(Math.floor(Math.random() * 2001) + 1000);
searchInput = getEditableElement(queryFirst(productSearchSelectors) || searchBox);
}
const isRealEditable = !!searchInput && (
searchInput.tagName === "INPUT" || searchInput.tagName === "TEXTAREA" || searchInput.contentEditable === "true"
);
console.log("[千帆助手] STEP 1.5: 搜索框元素", {
tag: searchInput && searchInput.tagName,
isRealEditable,
cls: searchInput && searchInput.className,
});
// B2: 输入搜索词(逐字符输入 + 输入后校验,避免一次性赋值被吞导致标题不完整)
if (isRealEditable) {
clickElement(searchInput);
const typedValue = await typeTextRobust(searchInput, searchTerm);
await sleep(Math.floor(Math.random() * 2001) + 1000);
if (searchInput.value !== searchTerm) {
console.warn("[千帆助手] 搜索框内容与预期不符,重试一次", {
expected: searchTerm, actual: searchInput.value, typedValue,
});
await sleep(Math.floor(Math.random() * 2001) + 1000);
await typeTextRobust(searchInput, searchTerm);
await sleep(Math.floor(Math.random() * 2001) + 1000);
if (searchInput.value !== searchTerm) {
console.warn("[千帆助手] 重试后搜索框内容仍不完整,继续尝试搜索", {
expected: searchTerm, actual: searchInput.value,
});
}
}
} else {
// 兜底:找不到真正的 input/contenteditable直接对着容器逐字符派发键盘事件
// (部分自定义组件靠原生键盘事件驱动,不依赖 value/input 事件)
console.warn("[千帆助手] 未找到真正的可编辑输入框,改用逐字符键盘事件兜底输入");
searchInput = searchBox;
clickElement(searchInput);
for (const ch of searchTerm) {
searchInput.dispatchEvent(new KeyboardEvent("keydown", {key: ch, bubbles: true}));
searchInput.dispatchEvent(new KeyboardEvent("keypress", {key: ch, bubbles: true}));
searchInput.dispatchEvent(new InputEvent("input", {bubbles: true, inputType: "insertText", data: ch}));
searchInput.dispatchEvent(new KeyboardEvent("keyup", {key: ch, bubbles: true}));
}
}
console.log("[千帆助手] 搜索框当前内容:", searchInput.value ?? searchInput.textContent);
await sleep(Math.floor(Math.random() * 2001) + 1000);
searchInput.dispatchEvent(new Event("change", {bubbles: true}));
searchInput.dispatchEvent(new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
keyCode: 13,
which: 13,
bubbles: true
}));
searchInput.dispatchEvent(new KeyboardEvent("keypress", {
key: "Enter",
code: "Enter",
keyCode: 13,
which: 13,
bubbles: true
}));
searchInput.dispatchEvent(new KeyboardEvent("keyup", {
key: "Enter",
code: "Enter",
keyCode: 13,
which: 13,
bubbles: true
}));
await sleep(Math.floor(Math.random() * 2001) + 1000);
// B3: 校验搜索是否真的生效 —— 结果列表里要能找到"标题包含 searchTerm"的商品卡片。
// 找不到的话,先尝试点搜索按钮/图标兜底,再多等几秒重试,而不是直接退而求其次点默认第一个。
const listContainer = document.querySelector(S.productListContainer);
let targetLink = findGoNoteLinkForProduct(listContainer, searchTerm);
if (!targetLink) {
const searchBtn = queryFirst([
".list-header-right button",
".list-header-right [class*='search-icon']",
".list-header-right [class*='searchBtn']",
".list-header-right svg",
]);
if (searchBtn) {
console.log("[千帆助手] Enter 未生效或搜索未命中,尝试点击搜索按钮/图标兜底");
clickElement(searchBtn);
await sleep(Math.floor(Math.random() * 2001) + 1000);
targetLink = findGoNoteLinkForProduct(listContainer, searchTerm);
}
}
// 再多等几次,给搜索请求返回结果留时间(最多再等 5 秒)
for (let i = 0; i < 5 && !targetLink; i++) {
await sleep(Math.floor(Math.random() * 2001) + 1000);
targetLink = findGoNoteLinkForProduct(document.querySelector(S.productListContainer), searchTerm);
}
if (!targetLink) {
// 最后兜底:只要页面上任意位置能匹配到"标题含 searchTerm 的卡片 + 去发笔记链接"就用,
// 但绝不退化为"随便点容器里第一个去发笔记"(那样等于点了默认第一条,选错商品)。
targetLink = findGoNoteLinkForProduct(document.body, searchTerm);
}
if (!targetLink) {
alertSelectorFailure("product_link", "a:去发笔记",
`搜索"${searchTerm}"后,未在结果列表中找到匹配该商品标题的"去发笔记"链接(很可能是搜索没有真正生效,列表仍是默认全部商品)`,
[S.productListContainer, "a.d-link", "a:去发笔记", "button:去发笔记"]);
throw new Error(`未找到 ${searchTerm} 的"去发笔记"链接,商品可能不存在,或搜索未生效`);
}
console.log("[千帆助手] STEP 2: 找到去发笔记链接,点击");
clickElement(targetLink);
try {
await waitForPublishPage(20000);
console.log("[千帆助手] STEP 3: 已进入发布页");
} catch (e) {
throw new Error("点击去发笔记后未能跳转到发布页: " + e.message);
}
}
// --- 发布页 ---
if (!isPublishPage()) {
console.warn("[千帆助手] 无法确认当前在发布页,仍尝试继续");
}
// B3.5: 等待发布页主容器渲染完成URL 变化不等于 DOM 渲染完成,之前 STEP4 常常在容器还没挂载时就查询,误判"未找到"
console.log("[千帆助手] STEP 3.5: 等待发布页容器渲染");
await waitForAnyElement(["#ark .outarea.upload-c", "#ark .outarea", "#ark"], 10000);
await sleep(Math.floor(Math.random() * 2001) + 1000);
// B4: 点击"上传图文" tab用轮询等待而不是一次性查询
console.log("[千帆助手] STEP 4: 点击上传图文 tab");
const imageTextTab = await waitForAnyElement([
S.imageTextTab,
'.outarea.upload-c .header div:nth-child(2) span',
'.outarea.upload-c .header div:nth-child(2)',
".creator-tab:nth-child(2) > .title",
".creator-tab:nth-child(2)",
'[class*="creator-tab"]:nth-child(2)',
], 8000);
if (imageTextTab) {
clickElement(imageTextTab);
await sleep(Math.floor(Math.random() * 2001) + 1000);
} else {
console.warn("[千帆助手] 未找到上传图文 tab可能已在图文模式也可能选择器已过期");
alertSelectorFailure("image_text_tab", S.imageTextTab, "image-text tab not found",
[S.imageTextTab, '.outarea.upload-c .header div:nth-child(2) span', ".creator-tab:nth-child(2)"]);
}
// B5: 上传图片
if (note.image_paths && note.image_paths.length > 0) {
console.log(`[千帆助手] STEP 5: 上传 ${note.image_paths.length} 张图片`);
try {
const fileInput = await waitForAnyElement([
S.imageUploadInput,
'input[type="file"][multiple]',
"input.upload-input",
'input[type="file"]',
], 8000);
if (fileInput) {
await uploadFilesToFileInput(fileInput, note.image_paths);
console.log("[千帆助手] STEP 5: 图片已注入 file input");
} else {
const dropZone = queryFirst([S.imageDropZone, ".upload-wrapper", ".upload-content"]);
if (dropZone) {
await simulateDropUpload(dropZone, note.image_paths);
console.log("[千帆助手] STEP 5: 图片已通过拖拽注入");
} else {
alertSelectorFailure("image_upload", S.imageUploadInput, "image upload area not found",
[S.imageUploadInput, 'input[type="file"][multiple]', "input.upload-input", ".upload-wrapper"]);
throw new Error("未找到图片上传区域");
}
}
// 等待上传完成:图片上传通常比较慢,之前只等最多 20 秒,很容易在图片还没传完时
// 就往下走,导致后面填标题/正文时其实图片还没上传成功。
// 这里改成:最多等 90 秒,每秒检查一次"上传中"标志是否消失 + 已生成的图片预览数量,
// 并每 5 秒打印一次进度日志,方便在控制台里看到它到底卡在哪。
await waitForImageUploadComplete(note.image_paths.length, 20000);
} catch (e) {
throw new Error(`图片上传失败: ${e.message}`);
}
}
// B6: 填标题(发布页标题字段 .focus > .d-text区别于选品页搜索框
if (note.title) {
console.log("[千帆助手] STEP 6: 填写标题", {title: note.title});
let titleField = null;
for (let i = 0; i < 10; i++) {
titleField = queryFirst([
S.titleInputExact,
'.outarea.publish-c .titleInput input',
".focus > .d-text",
'input[placeholder*="标题"]',
'input[class*="title"]',
"input.d-text",
]);
if (titleField) break;
await sleep(Math.floor(Math.random() * 2001) + 1000);
}
if (titleField) {
try {
titleField.click();
} catch (_) {
}
simulateInput(titleField, note.title);
titleField.dispatchEvent(new Event("change", {bubbles: true}));
await sleep(Math.floor(Math.random() * 2001) + 1000);
console.log("[千帆助手] STEP 6: 标题已填写");
} else {
alertSelectorFailure("title_field", ".focus > .d-text", "title field not found",
[".focus > .d-text", 'input[placeholder*="标题"]', 'input[class*="title"]', "input.d-text"]);
}
}
// B7: 定位正文编辑器Quill div.ql-editor
console.log("[千帆助手] STEP 7: 定位正文编辑器");
let editor = null;
for (let i = 0; i < 10; i++) {
editor = queryFirst([
S.contentEditorExact,
"#quillEditor .ql-editor",
"#quillEditor > div",
S.contentEditor,
S.contentEditorAlt,
"div.ql-editor",
'[contenteditable="true"]',
".ProseMirror",
]);
if (editor) break;
await sleep(Math.floor(Math.random() * 2001) + 1000);
}
if (!editor) {
alertSelectorFailure("content_editor", "div.ql-editor", "editor not found",
[S.contentEditor, S.contentEditorAlt, "div.ql-editor", '[contenteditable="true"]', ".ProseMirror"]);
throw new Error("未找到正文编辑器");
}
try {
editor.focus();
editor.click();
} catch (_) {
}
await sleep(Math.floor(Math.random() * 2001) + 1000);
// B8: 填正文typeIntoQuill 逐字 execCommandCJK 友好;失败降级 simulateInput
if (note.content) {
console.log(`[千帆助手] STEP 8: 填写正文 (${note.content.length} 字)`);
try {
await typeIntoQuill(editor, note.content);
} catch (e) {
console.warn("[千帆助手] typeIntoQuill 失败,降级 simulateInput:", e.message);
simulateInput(editor, note.content);
}
await sleep(Math.floor(Math.random() * 2001) + 1000);
}
// B9: 追加热搜词(直接接在正文后面的纯文本,不走 # mention 下拉选择流程)
if (note.topics && note.topics.length > 0) {
console.log("[千帆助手] STEP 9: 追加热搜词", {topics: note.topics});
const hotWords = note.topics.slice(0, 5).join(" ");
try {
await typeIntoQuill(editor, " " + hotWords);
} catch (e) {
console.warn("[千帆助手] typeIntoQuill 追加热搜词失败,降级 simulateInput:", e.message);
simulateInput(editor, (note.content || "") + " " + hotWords);
}
await sleep(Math.floor(Math.random() * 2001) + 1000);
}
// B10: 滚动到发布区,确保发布按钮在视口内可点击
console.log("[千帆助手] STEP 10: 滚动到发布区");
const publishContainer = queryFirst([
"div.note-main-content-container",
".note-publish-wrap",
]);
if (publishContainer) {
try {
publishContainer.scrollIntoView({behavior: "smooth", block: "end"});
} catch (_) {
}
await sleep(Math.floor(Math.random() * 2001) + 1000);
}
// B11: 点击发布
console.log("[千帆助手] STEP 11: 点击发布按钮");
await sleep(Math.floor(Math.random() * 2001) + 1000);
const publishBtn = queryFirst([S.publishButtonExact, ".outarea.publish-c .submit .publishBtn"])
|| findButtonByText("发布") || findButtonByText("确认发布")
|| queryFirst([".publishBtn > .d-button-content", ".publishBtn"]);
if (!publishBtn) {
alertSelectorFailure("publish_button", ".publishBtn > .d-button-content", "publish button not found",
[".publishBtn > .d-button-content", ".publishBtn", 'button:发布']);
throw new Error("未找到发布按钮");
}
clickElement(publishBtn);
console.log("[千帆助手] 已点击发布按钮");
const result = await waitForPublishResult();
if (!result.success) {
throw new Error(result.error || "发布后未检测到成功状态");
}
// 停在"笔记发布成功"页的话,需要手动点继续按钮才会跳回选品页,
// 之前这里完全没处理,导致每次发完一篇就停在成功页,看起来像"卡住/暂停了"。
let continuedToItemSelect = isItemSelectPage();
if (result.onSuccessPage || isPublishSuccessPage()) {
continuedToItemSelect = await continueFromPublishSuccessPage();
}
console.log(`[千帆助手] 发布完成: ${note.title}`);
return {success: true, publishedAt: new Date().toISOString(), continuedToItemSelect};
}
// Selector failure alert含页面同类型元素摘要便于现场调选择器
function alertSelectorFailure(step, selector, error, triedSelectors) {
const tried = Array.isArray(triedSelectors) && triedSelectors.length ? triedSelectors : [selector];
const ctx = {
url: window.location.href,
tried,
inputs: Array.from(document.querySelectorAll("input")).slice(0, 20).map((el) => ({
cls: el.className,
ph: el.placeholder,
type: el.type
})),
buttons: Array.from(document.querySelectorAll("button")).slice(0, 20).map((el) => ({
cls: el.className,
text: el.textContent.trim().slice(0, 20)
})),
editables: Array.from(document.querySelectorAll('[contenteditable="true"], .ql-editor, .ProseMirror')).slice(0, 10).map((el) => ({
tag: el.tagName,
cls: el.className
})),
links: Array.from(document.querySelectorAll("a")).slice(0, 20).map((el) => ({
cls: el.className,
text: el.textContent.trim().slice(0, 20)
})),
dTexts: Array.from(document.querySelectorAll("[class*='d-text']")).slice(0, 20).map((el) => ({
tag: el.tagName,
cls: el.className
})),
};
console.error(`[千帆助手] 选择器失效: ${step}`, {selector, error, ctx});
console.error(`[千帆助手] 可复制诊断信息(${step}):\n` + JSON.stringify(ctx, null, 2));
chrome.runtime.sendMessage({
type: "SELECTOR_FAILURE",
step,
selector,
error,
url: ctx.url,
timestamp: new Date().toISOString(),
}).catch(() => {
});
}
// ==================== 页面状态检测 ====================
function isItemSelectPage() {
return window.location.href.includes("publish-item-select") ||
window.location.href.includes("step=itemSelect");
}
function isPublishSuccessPage() {
return window.location.href.includes("publish-success");
}
function isPublishPage() {
const url = window.location.href;
// Must be on千帆 AND be on a publish page, NOT the item-select page, NOT the success page
if (!url.includes("ark.xiaohongshu.com")) return false;
if (url.includes("publish-item-select") || url.includes("step=itemSelect")) return false;
if (url.includes("publish-success")) return false;
return url.includes("/app-note/publish") ||
url.includes("step=notePublish") ||
url.includes("mainPublishType=") ||
url.includes("step=contentEdit");
}
// ==================== 消息监听 ====================
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "EXECUTE_PUBLISH") {
executePublish(msg.task)
.then((result) => sendResponse({success: true, result}))
.catch((e) => sendResponse({success: false, error: e.message}));
return true;
}
if (msg.type === "PING") {
sendResponse({
ok: true,
url: window.location.href,
isItemSelectPage: isItemSelectPage(),
isPublishPage: isPublishPage(),
hasEditor: !!document.querySelector('[contenteditable="true"], .ql-editor'),
hasUploadInput: !!document.querySelector('input[type="file"]'),
hasSearchInput: !!document.querySelector('input[placeholder*="商品"]'),
});
return false;
}
if (msg.type === "GET_PAGE_INFO") {
const info = {
url: window.location.href,
isItemSelectPage: isItemSelectPage(),
isPublishPage: isPublishPage(),
hasEditor: !!document.querySelector('[contenteditable="true"], .ql-editor'),
hasUploadInput: !!document.querySelector('input[type="file"]'),
hasPublishButton: !!findButtonByText("发布"),
hasSearchInput: !!document.querySelector('input[placeholder*="商品"]'),
};
sendResponse(info);
return false;
}
});
console.log("[千帆助手] 内容脚本 v2 已加载URL:", window.location.href);