Skip to content

Commit 56ac98c

Browse files
author
陈家名
authored
fix(weread): decode search HTML entities
Decode rendered search-card title and author entities for reader URL matching while keeping output identity from the public API and preserving typed error behavior.
1 parent 8aa48b1 commit 56ac98c

2 files changed

Lines changed: 97 additions & 2 deletions

File tree

clis/weread/search-regression.test.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,86 @@ describe('weread/search regression', () => {
245245
},
246246
]);
247247
});
248+
it('decodes named and astral HTML entities before matching search cards', async () => {
249+
const command = getRegistry().get('weread/search');
250+
expect(command?.func).toBeTypeOf('function');
251+
const fetchMock = vi.fn()
252+
.mockResolvedValueOnce({
253+
ok: true,
254+
json: () => Promise.resolve({
255+
books: [
256+
{
257+
bookInfo: {
258+
title: 'A <B> 😊',
259+
author: "O'Neil & Co",
260+
bookId: 'entity-book',
261+
},
262+
},
263+
],
264+
}),
265+
})
266+
.mockResolvedValueOnce({
267+
ok: true,
268+
text: () => Promise.resolve(`
269+
<ul class="search_bookDetail_list">
270+
<li class="wr_bookList_item">
271+
<a class="wr_bookList_item_link" href="/web/reader/entity-reader"></a>
272+
<p class="wr_bookList_item_title">A &lt;B&gt; &#x1F60A;</p>
273+
<p class="wr_bookList_item_author">O&apos;Neil &amp; Co</p>
274+
</li>
275+
</ul>
276+
`),
277+
});
278+
vi.stubGlobal('fetch', fetchMock);
279+
const result = await command.func({ query: 'entities', limit: 5 });
280+
expect(result).toEqual([
281+
{
282+
rank: 1,
283+
title: 'A <B> 😊',
284+
author: "O'Neil & Co",
285+
bookId: 'entity-book',
286+
url: 'https://weread.qq.com/web/reader/entity-reader',
287+
},
288+
]);
289+
});
290+
it('leaves invalid numeric HTML entities literal instead of throwing raw RangeError', async () => {
291+
const command = getRegistry().get('weread/search');
292+
expect(command?.func).toBeTypeOf('function');
293+
const fetchMock = vi.fn()
294+
.mockResolvedValueOnce({
295+
ok: true,
296+
json: () => Promise.resolve({
297+
books: [
298+
{
299+
bookInfo: {
300+
title: 'Bad &#xFFFFFFFF; Entity',
301+
author: 'Tester',
302+
bookId: 'bad-entity-book',
303+
},
304+
},
305+
],
306+
}),
307+
})
308+
.mockResolvedValueOnce({
309+
ok: true,
310+
text: () => Promise.resolve(`
311+
<ul class="search_bookDetail_list">
312+
<li class="wr_bookList_item">
313+
<a class="wr_bookList_item_link" href="/web/reader/bad-entity-reader"></a>
314+
<p class="wr_bookList_item_title">Bad &#xFFFFFFFF; Entity</p>
315+
<p class="wr_bookList_item_author">Tester</p>
316+
</li>
317+
</ul>
318+
`),
319+
});
320+
vi.stubGlobal('fetch', fetchMock);
321+
const result = await command.func({ query: 'bad entity', limit: 5 });
322+
expect(result[0]).toMatchObject({
323+
title: 'Bad &#xFFFFFFFF; Entity',
324+
bookId: 'bad-entity-book',
325+
url: 'https://weread.qq.com/web/reader/bad-entity-reader',
326+
});
327+
});
248328
it('leaves urls empty when same-title results are ambiguous and html cards have no author', async () => {
249329
const command = getRegistry().get('weread/search');
250330
expect(command?.func).toBeTypeOf('function');

clis/weread/search.js

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,29 @@
11
import { cli, Strategy } from '@jackwener/opencli/registry';
22
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
33
import { fetchWebApi, WEREAD_UA, WEREAD_WEB_ORIGIN } from './utils.js';
4+
function decodeNumericHtmlEntity(raw, radix) {
5+
const codePoint = parseInt(raw, radix);
6+
if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10FFFF) {
7+
return null;
8+
}
9+
try {
10+
return String.fromCodePoint(codePoint);
11+
}
12+
catch {
13+
return null;
14+
}
15+
}
416
function decodeHtmlText(value) {
517
return value
618
.replace(/<[^>]+>/g, '')
7-
.replace(/&#x([0-9a-fA-F]+);/gi, (_, n) => String.fromCharCode(parseInt(n, 16)))
8-
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
19+
.replace(/&#x([0-9a-fA-F]+);/gi, (entity, n) => decodeNumericHtmlEntity(n, 16) ?? entity)
20+
.replace(/&#(\d+);/g, (entity, n) => decodeNumericHtmlEntity(n, 10) ?? entity)
921
.replace(/&nbsp;/g, ' ')
1022
.replace(/&amp;/g, '&')
1123
.replace(/&quot;/g, '"')
24+
.replace(/&apos;/g, "'")
25+
.replace(/&lt;/g, '<')
26+
.replace(/&gt;/g, '>')
1227
.trim();
1328
}
1429
function normalizeSearchTitle(value) {

0 commit comments

Comments
 (0)