Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

261 lines
12 KiB
JavaScript

/**
* Xiaohongshu delete-note: remove a published note via creator center UI.
*
* Flow:
* 1. Navigate to creator note-manager
* 2. Switch to "已发布" tab (delete is only available on published notes;
* "审核中" and "未通过" rows do not expose a web delete entry, only mobile)
* 3. Locate the row whose `data-impression` JSON contains the target noteId
* 4. Click the inline `<span class="control data-del">` action
* 5. Click "确定" in the `.d-modal-footer` confirmation modal
* 6. Poll for the row disappearing from the list
*
* Requires: logged into creator.xiaohongshu.com in Chrome.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CliError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
const NOTE_MANAGER_URL = 'https://creator.xiaohongshu.com/new/note-manager';
const ROW_SETTLE_MS = 3000;
const MODAL_SETTLE_MS = 2000;
const VERIFY_TIMEOUT_MS = 10_000;
const VERIFY_POLL_MS = 1000;
const NOTE_ID_RE = /^[0-9a-f]{24}$/i;
function unwrapEvaluateResult(payload) {
if (payload && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
return payload.data;
}
return payload;
}
function requireEvaluateString(payload, context) {
if (typeof payload !== 'string') {
throw new CommandExecutionError(`xiaohongshu/delete-note: malformed ${context} payload`);
}
return payload;
}
function requireEvaluateBoolean(payload, context) {
if (typeof payload !== 'boolean') {
throw new CommandExecutionError(`xiaohongshu/delete-note: malformed ${context} payload`);
}
return payload;
}
function requireEvaluateObject(payload, context) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new CommandExecutionError(`xiaohongshu/delete-note: malformed ${context} payload`);
}
return payload;
}
function requireActionResult(payload, context) {
const result = requireEvaluateObject(payload, context);
if (typeof result.ok !== 'boolean') {
throw new CommandExecutionError(`xiaohongshu/delete-note: malformed ${context} payload`);
}
return result;
}
function isXiaohongshuHost(hostname) {
const host = String(hostname || '').toLowerCase();
return host === 'xiaohongshu.com' || host.endsWith('.xiaohongshu.com');
}
function isSupportedQueryNoteUrl(url) {
return url.hostname.toLowerCase() === 'creator.xiaohongshu.com'
&& url.pathname.replace(/\/+$/, '') === '/statistics/note-detail';
}
function normalizeNoteId(input) {
const raw = String(input ?? '').trim();
if (!raw) {
throw new ArgumentError('xiaohongshu/delete-note: note-id cannot be empty');
}
if (NOTE_ID_RE.test(raw))
return raw.toLowerCase();
if (!/^https:\/\//i.test(raw)) {
throw new ArgumentError('xiaohongshu/delete-note: note-id must be a 24-character Xiaohongshu note ID or an exact Xiaohongshu note URL');
}
let url;
try {
url = new URL(raw);
}
catch {
throw new ArgumentError('xiaohongshu/delete-note: invalid note URL');
}
if (url.protocol !== 'https:' || url.username || url.password || url.port || !isXiaohongshuHost(url.hostname)) {
throw new ArgumentError('xiaohongshu/delete-note: note URL must be an exact https://*.xiaohongshu.com URL');
}
const queryId = url.searchParams.get('noteId') || url.searchParams.get('note_id');
if (queryId && NOTE_ID_RE.test(queryId) && isSupportedQueryNoteUrl(url))
return queryId.toLowerCase();
const pathMatch = url.pathname.match(/^\/(?:explore|note|search_result|discovery\/item)\/([0-9a-f]{24})\/?$/i)
|| url.pathname.match(/^\/user\/profile\/[^/?#]+\/([0-9a-f]{24})\/?$/i);
if (pathMatch)
return pathMatch[1].toLowerCase();
throw new ArgumentError('xiaohongshu/delete-note: note URL must contain a 24-character note ID');
}
function buildLocateAndMaybeDeleteScript(noteId, shouldClick) {
return `
(cfg => {
const { targetId, shouldClick } = cfg;
const isVisible = (el) => !!el && el.offsetParent !== null;
const matchesNoteId = (impressionRaw) => {
if (!impressionRaw) return false;
try {
const parsed = JSON.parse(impressionRaw);
const id = parsed && parsed.noteTarget && parsed.noteTarget.value && parsed.noteTarget.value.noteId;
return typeof id === 'string' && id === targetId;
} catch {
return false;
}
};
const notes = Array.from(document.querySelectorAll('.note')).filter(isVisible);
for (const note of notes) {
if (matchesNoteId(note.getAttribute('data-impression'))) {
const del = note.querySelector('span.control.data-del');
if (!del || !isVisible(del)) {
return { ok: false, kind: 'no_delete_action', visibleRows: notes.length };
}
if (!shouldClick) {
return { ok: true, clicked: false };
}
del.click();
return { ok: true, clicked: true };
}
}
return { ok: false, kind: 'not_found', visibleRows: notes.length };
})(${JSON.stringify({ targetId: noteId, shouldClick })})
`;
}
function buildVerifyGoneScript(noteId) {
return `
(targetId => {
const matchesNoteId = (impressionRaw) => {
if (!impressionRaw) return false;
try {
const parsed = JSON.parse(impressionRaw);
const id = parsed && parsed.noteTarget && parsed.noteTarget.value && parsed.noteTarget.value.noteId;
return typeof id === 'string' && id === targetId;
} catch {
return false;
}
};
const notes = Array.from(document.querySelectorAll('.note'));
return notes.some((n) => matchesNoteId(n.getAttribute('data-impression')));
})(${JSON.stringify(noteId)})
`;
}
cli({
site: 'xiaohongshu',
name: 'delete-note',
access: 'write',
description: '删除小红书已发布笔记 (creator center UI automation)',
domain: 'creator.xiaohongshu.com',
strategy: Strategy.COOKIE,
navigateBefore: false,
browser: true,
args: [
{
name: 'note-id',
required: true,
positional: true,
help: 'Note ID (e.g. 6a08ba0b000000000702a893 from xiaohongshu creator-notes / URL)',
},
{
name: 'execute',
type: 'boolean',
default: false,
help: 'Actually click delete + confirm. Default is dry-run target verification only.',
},
],
columns: ['status', 'note_id', 'message'],
func: async (page, kwargs) => {
try {
const noteId = normalizeNoteId(kwargs['note-id']);
const execute = kwargs.execute === true;
await page.goto(NOTE_MANAGER_URL);
await page.wait({ time: ROW_SETTLE_MS / 1000 });
// Detect login redirect (creator.xiaohongshu.com bounces to /login on auth failure)
const currentUrl = requireEvaluateString(unwrapEvaluateResult(await page.evaluate('() => location.href')), 'current-url');
if (typeof currentUrl === 'string' && /\/login(?:[/?#]|$)/i.test(new URL(currentUrl).pathname + new URL(currentUrl).search)) {
throw new AuthRequiredError('creator.xiaohongshu.com');
}
// Step 1: ensure 已发布 tab is active (delete only exposed there).
const tabClicked = requireEvaluateBoolean(unwrapEvaluateResult(await page.evaluate(`
() => {
const isVisible = (el) => !!el && el.offsetParent !== null;
for (const el of document.querySelectorAll('a, button, [role="tab"], div')) {
const text = (el.innerText || el.textContent || '').trim();
if (text === '已发布' && isVisible(el)) {
el.click();
return true;
}
}
return false;
}
`)), 'published-tab');
if (!tabClicked) {
throw new CommandExecutionError('xiaohongshu/delete-note: 已发布 tab not found on note-manager; xhs creator UI may have changed.');
}
await page.wait({ time: ROW_SETTLE_MS / 1000 });
// Step 2: locate the .note row whose data-impression JSON carries the
// exact `noteId` field. Dry-run stops here; execute clicks delete.
// Substring matching on the raw attribute would risk matching unrelated
// fields whose values happen to share the noteId prefix, so parse the JSON
// and compare `noteTarget.value.noteId` explicitly.
const initResult = requireActionResult(unwrapEvaluateResult(await page.evaluate(buildLocateAndMaybeDeleteScript(noteId, execute))), 'locate-note');
if (!initResult?.ok) {
if (initResult?.kind === 'not_found') {
throw new EmptyResultError('xiaohongshu/delete-note', `Note ${noteId} not visible in the 已发布 tab. Verify the note belongs to the logged-in account and has cleared review (审核中 / 未通过 rows have no web delete entry).`);
}
if (initResult?.kind === 'no_delete_action') {
throw new CommandExecutionError(`xiaohongshu/delete-note: note ${noteId} row found but no delete action visible; xhs creator UI may have changed.`);
}
throw new CommandExecutionError('xiaohongshu/delete-note: failed to locate note row');
}
if (!execute) {
return [{ status: 'dry-run', note_id: noteId, message: 'Target note row and delete action verified. Re-run with --execute to delete.' }];
}
await page.wait({ time: MODAL_SETTLE_MS / 1000 });
// Step 3: click "确定" in the `.d-modal-footer` confirmation modal.
const confirmResult = requireActionResult(unwrapEvaluateResult(await page.evaluate(`
() => {
const isVisible = (el) => !!el && el.offsetParent !== null;
const footer = Array.from(document.querySelectorAll('.d-modal-footer')).find(isVisible);
if (!footer) return { ok: false, kind: 'no_modal' };
const buttons = Array.from(footer.querySelectorAll('button, [role="button"]')).filter(isVisible);
const confirmBtn = buttons.find((b) => (b.innerText || b.textContent || '').trim() === '确定');
if (!confirmBtn) return { ok: false, kind: 'no_confirm', labels: buttons.map(b => (b.innerText || '').trim()) };
confirmBtn.click();
return { ok: true };
}
`)), 'confirm-modal');
if (!confirmResult?.ok) {
throw new CommandExecutionError(`xiaohongshu/delete-note: confirmation modal step failed (${confirmResult?.kind ?? 'unknown'})`);
}
// Step 4: poll for row removal (proves the delete actually committed,
// not just the modal was clicked). Iteration-bounded rather than
// wall-clock so tests with a mocked `page.wait` exhaust the loop
// quickly instead of stalling on real time.
const VERIFY_ITERATIONS = Math.ceil(VERIFY_TIMEOUT_MS / VERIFY_POLL_MS);
let stillPresent = true;
for (let i = 0; i < VERIFY_ITERATIONS; i++) {
await page.wait({ time: VERIFY_POLL_MS / 1000 });
const probe = requireEvaluateBoolean(unwrapEvaluateResult(await page.evaluate(buildVerifyGoneScript(noteId))), 'verify-gone');
if (probe === false) {
stillPresent = false;
break;
}
}
if (stillPresent) {
throw new CommandExecutionError(`xiaohongshu/delete-note: note ${noteId} still visible after confirm click; deletion may not have committed.`);
}
return [{ status: 'deleted', note_id: noteId, message: 'Delete confirmed and note row disappeared.' }];
}
catch (err) {
if (err instanceof CliError)
throw err;
throw new CommandExecutionError(`xiaohongshu/delete-note failed: ${err?.message ?? String(err)}`);
}
},
});
export const __test__ = {
normalizeNoteId,
buildLocateAndMaybeDeleteScript,
buildVerifyGoneScript,
};