Skip to content

Commit 36b6f29

Browse files
committed
fix(xiaohongshu+rednote/search): fall back to href-based note cards when section.note-item class is dropped (#1506)
Issue #1506 reports `opencli xiaohongshu search` returning `[]` even though the page visibly has results. Trace evidence: xhs ships a render variant where each note card is a bare `<section>` (no `note-item` class), so the three `section.note-item` selectors in this file all match zero elements. Three call sites in the shared search IIFEs now use the same defensive selector strategy: try the legacy `section.note-item` class first, then fall back to any `<section>` that wraps a `/search_result/...` or `/explore/...` link. The change is in the xiaohongshu file so the rednote adapter (which imports `buildSearchExtractJs` and `buildScrollUntilJs` from here) picks it up automatically. Extraction-side title selector also gets a fallback: when no `.title` / `.note-title` element matches, read the first `<span>` inside the search-result link, which is where the bare-section render puts the caption per the trace. ## Verification `npx vitest run --project adapter clis/xiaohongshu/`: 105/105 green (existing test suite unchanged, passes on both legacy and fallback paths). Live verify on rednote (same code path, account-safe): ``` $ opencli rednote search "美食" --limit 3 -f json [ {rank:1, title:"在朋友家吃过一次..."}, {rank:2, title:"我的15💰晚餐..."}, {rank:3, title:"干净饮食🫛..."} ] ``` Legacy `section.note-item` path is exercised here (rednote still renders the class) and returns identical row shape to before the fix, confirming no regression on the working path. Live verify on xiaohongshu cannot be performed here (no logged-in xhs session on the test machine; xhs account-ban risk per the project's operational guidance). The fix is structural: the new `<section>` shape the issue reporter traced is reachable through the fallback, and the existing test fixture keeps the legacy path green. `npx tsc --noEmit` clean. `npm run build` 815 manifest entries unchanged shape. `silent-column-drop` / `typed-error-lint` baselines unchanged. Closes #1506 Refs #1500
1 parent 04a5702 commit 36b6f29

1 file changed

Lines changed: 53 additions & 9 deletions

File tree

clis/xiaohongshu/search.js

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,19 @@ import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
1111
* Wait for search results or login wall using MutationObserver (max 5s).
1212
* Returns 'content' if note items appeared, 'login_wall' if login gate
1313
* detected, or 'timeout' if neither appeared within the deadline.
14+
*
15+
* Note-item detection tries the legacy `section.note-item` class first
16+
* (still observed in many sessions, including rednote) and falls back to
17+
* a `<section>` element containing a `/search_result/` or `/explore/`
18+
* link. Issue #1506 reports the class being dropped on some xhs renders.
1419
*/
1520
const WAIT_FOR_CONTENT_JS = `
1621
new Promise((resolve) => {
22+
const findNoteCard = () => document.querySelector(
23+
'section.note-item, section:has(a[href*="/search_result/"]), section:has(a[href*="/explore/"])'
24+
);
1725
const detect = () => {
18-
if (document.querySelector('section.note-item')) return 'content';
26+
if (findNoteCard()) return 'content';
1927
if (/登录后查看搜索结果/.test(document.body?.innerText || '')) return 'login_wall';
2028
return null;
2129
};
@@ -94,9 +102,22 @@ export function buildScrollUntilJs(targetCount, maxScrolls = 15) {
94102
const style = getComputedStyle(el);
95103
return style.display !== 'none' && style.visibility !== 'hidden';
96104
};
105+
// Note containers: legacy \`section.note-item\` first, fallback to
106+
// any \`<section>\` that wraps a search-result/explore note link
107+
// (#1506 reports the class being dropped on some xhs renders).
108+
const collectNoteCards = () => {
109+
const classMatches = document.querySelectorAll('section.note-item');
110+
if (classMatches.length > 0) return classMatches;
111+
const sections = new Set();
112+
for (const a of document.querySelectorAll('a[href*="/search_result/"], a[href*="/explore/"]')) {
113+
const section = a.closest('section');
114+
if (section) sections.add(section);
115+
}
116+
return sections;
117+
};
97118
const countItems = () => {
98119
let count = 0;
99-
for (const el of document.querySelectorAll('section.note-item')) {
120+
for (const el of collectNoteCards()) {
100121
if (isVisibleNote(el)) count++;
101122
}
102123
return count;
@@ -161,10 +182,24 @@ export function buildSearchExtractJs(webHost) {
161182
const results = [];
162183
const seen = new Set();
163184
164-
document.querySelectorAll('section.note-item').forEach(el => {
185+
// Note containers: legacy \`section.note-item\` first, fallback to any
186+
// \`<section>\` wrapping a search-result/explore link (#1506 reports the
187+
// class being dropped on some xhs renders).
188+
const collectNoteCards = () => {
189+
const classMatches = document.querySelectorAll('section.note-item');
190+
if (classMatches.length > 0) return classMatches;
191+
const sections = new Set();
192+
for (const a of document.querySelectorAll('a[href*="/search_result/"], a[href*="/explore/"]')) {
193+
const section = a.closest('section');
194+
if (section) sections.add(section);
195+
}
196+
return sections;
197+
};
198+
199+
for (const el of collectNoteCards()) {
165200
// Skip "related searches" sections
166-
if (el.classList.contains('query-note-item')) return;
167-
if (!isVisibleNote(el)) return;
201+
if (el.classList?.contains('query-note-item')) continue;
202+
if (!isVisibleNote(el)) continue;
168203
169204
const titleEl = el.querySelector('.title, .note-title, a.title, .footer .title span');
170205
const nameEl = el.querySelector('a.author .name, .author-name, .nick-name, .name');
@@ -184,20 +219,29 @@ export function buildSearchExtractJs(webHost) {
184219
const authorLinkEl = el.querySelector('a.author, a[href*="/user/profile/"]');
185220
186221
const url = normalizeUrl(detailLinkEl?.getAttribute('href') || '');
187-
if (!url) return;
222+
if (!url) continue;
188223
189224
const key = url;
190-
if (seen.has(key)) return;
225+
if (seen.has(key)) continue;
191226
seen.add(key);
192227
228+
// Fallback title: the new bare-section render keeps the note caption
229+
// inside the search_result anchor's first span, not in a class-named
230+
// .title element. Pull from there when the class-based pick is empty.
231+
let title = cleanText(titleEl?.textContent || '');
232+
if (!title) {
233+
const captionSpan = detailLinkEl?.querySelector('span');
234+
title = cleanText(captionSpan?.textContent || '');
235+
}
236+
193237
results.push({
194-
title: cleanText(titleEl?.textContent || ''),
238+
title,
195239
author,
196240
likes: cleanText(likesEl?.textContent || '0'),
197241
url,
198242
author_url: normalizeUrl(authorLinkEl?.getAttribute('href') || ''),
199243
});
200-
});
244+
}
201245
202246
return results;
203247
})()

0 commit comments

Comments
 (0)