Skip to content

Commit 48b8b3a

Browse files
committed
feat(xiaohongshu): add delete-note command to remove published notes
Adds `opencli xiaohongshu delete-note <note-id>` so the workflow that creates a note can also remove one without leaving the CLI, mirroring `weibo delete` (jackwener#1619 / jackwener#1620). The creator-center HTTP API requires the `X-S-Common` signature header that `publish.js` deliberately avoids, so this follows the same UI automation route. Flow: 1. Navigate to creator note-manager 2. Switch to "已发布" tab (delete entry only appears there; "审核中" and "未通过" rows have no web delete action, mobile app only) 3. Locate the `.note` 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 (iteration-bounded so tests with mocked `page.wait` exhaust the loop quickly) Typed errors: - /login redirect after navigation -> AuthRequiredError - target noteId not present in the rendered list -> EmptyResultError with a hint about review-state limitation - row found but no delete action visible -> CommandExecutionError - confirmation modal missing / no 确定 button -> CommandExecutionError - row still visible after the configured poll window -> CommandExecutionError Closes jackwener#1623. Verified live on macOS / opencli built locally (branch includes the jackwener#1613 shadow-DOM publish fix so a fresh test note could be published first): - Published a test note via `xiaohongshu publish` (note_id `6a08ba0b000000000702a893`, title "明洞街景测试 可删") - Waited for review to clear into "已发布" - `xiaohongshu delete-note 6a08ba0b000000000702a893` returned `[{ status: 'deleted', note_id: '6a08ba0b000000000702a893' }]` - Follow-up `xiaohongshu creator-notes` returns "No notes found" Unit tests: 7 / 7 cover happy path, empty-id ArgumentError, login redirect AuthRequiredError, row-not-found EmptyResultError, no-delete-action / no-modal / unverified-delete CommandExecutionError paths. Note on branch base: built on top of jackwener#1613 (xiaohongshu publish shadow-DOM fix) because the live verify required `xiaohongshu publish` to work end-to-end first. Will rebase onto main once jackwener#1613 lands.
1 parent 2667f4c commit 48b8b3a

3 files changed

Lines changed: 286 additions & 0 deletions

File tree

cli-manifest.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26327,6 +26327,32 @@
2632726327
"sourceFile": "xiaohongshu/creator-stats.js",
2632826328
"navigateBefore": false
2632926329
},
26330+
{
26331+
"site": "xiaohongshu",
26332+
"name": "delete-note",
26333+
"description": "删除小红书已发布笔记 (creator center UI automation)",
26334+
"access": "write",
26335+
"domain": "creator.xiaohongshu.com",
26336+
"strategy": "cookie",
26337+
"browser": true,
26338+
"args": [
26339+
{
26340+
"name": "note-id",
26341+
"type": "str",
26342+
"required": true,
26343+
"positional": true,
26344+
"help": "Note ID (e.g. 6a08ba0b000000000702a893 from xiaohongshu creator-notes / URL)"
26345+
}
26346+
],
26347+
"columns": [
26348+
"status",
26349+
"note_id"
26350+
],
26351+
"type": "js",
26352+
"modulePath": "xiaohongshu/delete-note.js",
26353+
"sourceFile": "xiaohongshu/delete-note.js",
26354+
"navigateBefore": false
26355+
},
2633026356
{
2633126357
"site": "xiaohongshu",
2633226358
"name": "download",

clis/xiaohongshu/delete-note.js

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* Xiaohongshu delete-note: remove a published note via creator center UI.
3+
*
4+
* Flow:
5+
* 1. Navigate to creator note-manager
6+
* 2. Switch to "已发布" tab (delete is only available on published notes;
7+
* "审核中" and "未通过" rows do not expose a web delete entry, only mobile)
8+
* 3. Locate the row whose `data-impression` JSON contains the target noteId
9+
* 4. Click the inline `<span class="control data-del">` action
10+
* 5. Click "确定" in the `.d-modal-footer` confirmation modal
11+
* 6. Poll for the row disappearing from the list
12+
*
13+
* Requires: logged into creator.xiaohongshu.com in Chrome.
14+
*/
15+
import { cli, Strategy } from '@jackwener/opencli/registry';
16+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
17+
const NOTE_MANAGER_URL = 'https://creator.xiaohongshu.com/new/note-manager';
18+
const ROW_SETTLE_MS = 3000;
19+
const MODAL_SETTLE_MS = 2000;
20+
const VERIFY_TIMEOUT_MS = 10_000;
21+
const VERIFY_POLL_MS = 1000;
22+
cli({
23+
site: 'xiaohongshu',
24+
name: 'delete-note',
25+
access: 'write',
26+
description: '删除小红书已发布笔记 (creator center UI automation)',
27+
domain: 'creator.xiaohongshu.com',
28+
strategy: Strategy.COOKIE,
29+
navigateBefore: false,
30+
browser: true,
31+
args: [
32+
{
33+
name: 'note-id',
34+
required: true,
35+
positional: true,
36+
help: 'Note ID (e.g. 6a08ba0b000000000702a893 from xiaohongshu creator-notes / URL)',
37+
},
38+
],
39+
columns: ['status', 'note_id'],
40+
func: async (page, kwargs) => {
41+
const noteId = String(kwargs['note-id'] ?? '').trim();
42+
if (!noteId) {
43+
throw new ArgumentError('xiaohongshu/delete-note: note-id cannot be empty');
44+
}
45+
await page.goto(NOTE_MANAGER_URL);
46+
await page.wait({ time: ROW_SETTLE_MS / 1000 });
47+
// Detect login redirect (creator.xiaohongshu.com bounces to /login on auth failure)
48+
const currentUrl = await page.evaluate('() => location.href');
49+
if (typeof currentUrl === 'string' && /\/login(\?|$)/i.test(currentUrl)) {
50+
throw new AuthRequiredError('creator.xiaohongshu.com');
51+
}
52+
// Step 1: ensure 已发布 tab is active (delete only exposed there).
53+
const tabClicked = await page.evaluate(`
54+
() => {
55+
const isVisible = (el) => !!el && el.offsetParent !== null;
56+
for (const el of document.querySelectorAll('a, button, [role="tab"], div')) {
57+
const text = (el.innerText || el.textContent || '').trim();
58+
if (text === '已发布' && isVisible(el)) {
59+
el.click();
60+
return true;
61+
}
62+
}
63+
return false;
64+
}
65+
`);
66+
if (!tabClicked) {
67+
throw new CommandExecutionError('xiaohongshu/delete-note: 已发布 tab not found on note-manager; xhs creator UI may have changed.');
68+
}
69+
await page.wait({ time: ROW_SETTLE_MS / 1000 });
70+
// Step 2: locate the .note row whose data-impression JSON carries the
71+
// exact `noteId` field, and click its `<span class="control data-del">`
72+
// action. Substring matching on the raw attribute would risk matching
73+
// unrelated fields whose values happen to share the noteId prefix, so
74+
// parse the JSON and compare `noteTarget.value.noteId` explicitly.
75+
const initResult = await page.evaluate(`
76+
(targetId => {
77+
const isVisible = (el) => !!el && el.offsetParent !== null;
78+
const matchesNoteId = (impressionRaw) => {
79+
if (!impressionRaw) return false;
80+
try {
81+
const parsed = JSON.parse(impressionRaw);
82+
const id = parsed && parsed.noteTarget && parsed.noteTarget.value && parsed.noteTarget.value.noteId;
83+
return typeof id === 'string' && id === targetId;
84+
} catch {
85+
return false;
86+
}
87+
};
88+
const notes = Array.from(document.querySelectorAll('.note')).filter(isVisible);
89+
for (const note of notes) {
90+
if (matchesNoteId(note.getAttribute('data-impression'))) {
91+
const del = note.querySelector('span.control.data-del');
92+
if (!del || !isVisible(del)) {
93+
return { ok: false, kind: 'no_delete_action', visibleRows: notes.length };
94+
}
95+
del.click();
96+
return { ok: true };
97+
}
98+
}
99+
return { ok: false, kind: 'not_found', visibleRows: notes.length };
100+
})(${JSON.stringify(noteId)})
101+
`);
102+
if (!initResult?.ok) {
103+
if (initResult?.kind === 'not_found') {
104+
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).`);
105+
}
106+
if (initResult?.kind === 'no_delete_action') {
107+
throw new CommandExecutionError(`xiaohongshu/delete-note: note ${noteId} row found but no delete action visible; xhs creator UI may have changed.`);
108+
}
109+
throw new CommandExecutionError('xiaohongshu/delete-note: failed to locate note row');
110+
}
111+
await page.wait({ time: MODAL_SETTLE_MS / 1000 });
112+
// Step 3: click "确定" in the `.d-modal-footer` confirmation modal.
113+
const confirmResult = await page.evaluate(`
114+
() => {
115+
const isVisible = (el) => !!el && el.offsetParent !== null;
116+
const footer = Array.from(document.querySelectorAll('.d-modal-footer')).find(isVisible);
117+
if (!footer) return { ok: false, kind: 'no_modal' };
118+
const buttons = Array.from(footer.querySelectorAll('button, [role="button"]')).filter(isVisible);
119+
const confirmBtn = buttons.find((b) => (b.innerText || b.textContent || '').trim() === '确定');
120+
if (!confirmBtn) return { ok: false, kind: 'no_confirm', labels: buttons.map(b => (b.innerText || '').trim()) };
121+
confirmBtn.click();
122+
return { ok: true };
123+
}
124+
`);
125+
if (!confirmResult?.ok) {
126+
throw new CommandExecutionError(`xiaohongshu delete-note: confirmation modal step failed (${confirmResult?.kind ?? 'unknown'})`);
127+
}
128+
// Step 4: poll for row removal (proves the delete actually committed,
129+
// not just the modal was clicked). Iteration-bounded rather than
130+
// wall-clock so tests with a mocked `page.wait` exhaust the loop
131+
// quickly instead of stalling on real time.
132+
const VERIFY_ITERATIONS = Math.ceil(VERIFY_TIMEOUT_MS / VERIFY_POLL_MS);
133+
let stillPresent = true;
134+
for (let i = 0; i < VERIFY_ITERATIONS; i++) {
135+
await page.wait({ time: VERIFY_POLL_MS / 1000 });
136+
const probe = await page.evaluate(`
137+
(targetId => {
138+
const matchesNoteId = (impressionRaw) => {
139+
if (!impressionRaw) return false;
140+
try {
141+
const parsed = JSON.parse(impressionRaw);
142+
const id = parsed && parsed.noteTarget && parsed.noteTarget.value && parsed.noteTarget.value.noteId;
143+
return typeof id === 'string' && id === targetId;
144+
} catch {
145+
return false;
146+
}
147+
};
148+
const notes = Array.from(document.querySelectorAll('.note'));
149+
return notes.some((n) => matchesNoteId(n.getAttribute('data-impression')));
150+
})(${JSON.stringify(noteId)})
151+
`);
152+
if (probe === false) {
153+
stillPresent = false;
154+
break;
155+
}
156+
}
157+
if (stillPresent) {
158+
throw new CommandExecutionError(`xiaohongshu/delete-note: note ${noteId} still visible after confirm click; deletion may not have committed.`);
159+
}
160+
return [{ status: 'deleted', note_id: noteId }];
161+
},
162+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { getRegistry } from '@jackwener/opencli/registry';
3+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4+
5+
import './delete-note.js';
6+
7+
function makePage(evaluateResults = []) {
8+
const evaluate = vi.fn();
9+
for (const r of evaluateResults) evaluate.mockResolvedValueOnce(r);
10+
evaluate.mockResolvedValue(undefined);
11+
return {
12+
goto: vi.fn().mockResolvedValue(undefined),
13+
wait: vi.fn().mockResolvedValue(undefined),
14+
evaluate,
15+
};
16+
}
17+
18+
describe('xiaohongshu delete-note command', () => {
19+
const getCommand = () => getRegistry().get('xiaohongshu/delete-note');
20+
21+
it('returns deleted status when delete + confirm + verify all succeed', async () => {
22+
const page = makePage([
23+
'https://creator.xiaohongshu.com/new/note-manager', // currentUrl
24+
true, // 已发布 tab click
25+
{ ok: true }, // initResult: row found + delete clicked
26+
{ ok: true }, // confirmResult
27+
false, // verify probe: row gone
28+
]);
29+
const result = await getCommand().func(page, { 'note-id': '6a08ba0b000000000702a893' });
30+
expect(result).toEqual([
31+
{ status: 'deleted', note_id: '6a08ba0b000000000702a893' },
32+
]);
33+
expect(page.goto).toHaveBeenCalledWith('https://creator.xiaohongshu.com/new/note-manager');
34+
});
35+
36+
it('throws ArgumentError when note-id is empty or whitespace', async () => {
37+
const page = makePage();
38+
await expect(getCommand().func(page, { 'note-id': '' })).rejects.toBeInstanceOf(ArgumentError);
39+
await expect(getCommand().func(page, { 'note-id': ' ' })).rejects.toBeInstanceOf(ArgumentError);
40+
expect(page.goto).not.toHaveBeenCalled();
41+
});
42+
43+
it('throws AuthRequiredError when redirected to login', async () => {
44+
const page = makePage([
45+
'https://creator.xiaohongshu.com/login?redirectReason=401',
46+
]);
47+
await expect(getCommand().func(page, { 'note-id': 'x' })).rejects.toBeInstanceOf(AuthRequiredError);
48+
});
49+
50+
it('throws CommandExecutionError when 已发布 tab cannot be clicked (UI drift)', async () => {
51+
const page = makePage([
52+
'https://creator.xiaohongshu.com/new/note-manager',
53+
false, // tab click returns false
54+
]);
55+
await expect(getCommand().func(page, { 'note-id': 'x' })).rejects.toThrowError(/ tab not found/);
56+
});
57+
58+
it('throws EmptyResultError when the note row is not in the 已发布 tab', async () => {
59+
const page = makePage([
60+
'https://creator.xiaohongshu.com/new/note-manager',
61+
true,
62+
{ ok: false, kind: 'not_found', visibleRows: 0 },
63+
]);
64+
await expect(getCommand().func(page, { 'note-id': 'missing-id' })).rejects.toBeInstanceOf(EmptyResultError);
65+
});
66+
67+
it('throws CommandExecutionError when the row has no visible delete action', async () => {
68+
const page = makePage([
69+
'https://creator.xiaohongshu.com/new/note-manager',
70+
true,
71+
{ ok: false, kind: 'no_delete_action', visibleRows: 1 },
72+
]);
73+
await expect(getCommand().func(page, { 'note-id': 'x' })).rejects.toThrowError(/no delete action/i);
74+
});
75+
76+
it('throws CommandExecutionError when the confirmation modal does not appear', async () => {
77+
const page = makePage([
78+
'https://creator.xiaohongshu.com/new/note-manager',
79+
true,
80+
{ ok: true },
81+
{ ok: false, kind: 'no_modal' },
82+
]);
83+
await expect(getCommand().func(page, { 'note-id': 'x' })).rejects.toThrowError(/no_modal/);
84+
});
85+
86+
it('throws CommandExecutionError when row stays visible after confirm (delete did not commit)', async () => {
87+
// verify probes return true (note still present) for the entire poll window.
88+
const probes = Array(15).fill(true);
89+
const page = makePage([
90+
'https://creator.xiaohongshu.com/new/note-manager',
91+
true,
92+
{ ok: true },
93+
{ ok: true },
94+
...probes,
95+
]);
96+
await expect(getCommand().func(page, { 'note-id': 'x' })).rejects.toThrowError(/still visible after confirm/i);
97+
});
98+
});

0 commit comments

Comments
 (0)