fix: derive Amazon result page from URL

This commit is contained in:
syrup 2026-09-02 10:36:04 +08:00
parent 1d60213a33
commit 3f34ed8341
3 changed files with 89 additions and 17 deletions

View File

@ -1,32 +1,86 @@
import { JSDOM } from 'jsdom'; import { JSDOM } from 'jsdom';
import { collectAmazonNextPageUrl } from '../src/page-worker/web-injectors/amazon-search-pagination'; import {
collectAmazonCurrentPage,
collectAmazonNextPageUrl,
} from '../src/page-worker/web-injectors/amazon-search-pagination';
const test = async (name: string, html: string, expected: string | null) => { const withDom = async <T>(url: string, html: string, callback: () => T | Promise<T>) => {
const dom = new JSDOM(html, { url: 'https://www.amazon.com/s?k=security+camera&page=1' }); const dom = new JSDOM(html, { url });
const previousDocument = globalThis.document; const previousDocument = globalThis.document;
const previousWindow = globalThis.window; const previousWindow = globalThis.window;
Object.assign(globalThis, { document: dom.window.document, window: dom.window }); Object.assign(globalThis, { document: dom.window.document, window: dom.window });
try { try {
const actual = await collectAmazonNextPageUrl(); return await callback();
if (actual !== expected) throw new Error(`${name}: expected ${expected}, received ${actual}`);
} finally { } finally {
Object.assign(globalThis, { document: previousDocument, window: previousWindow }); Object.assign(globalThis, { document: previousDocument, window: previousWindow });
dom.window.close(); dom.window.close();
} }
}; };
const testNextPage = async (name: string, html: string, expected: string | null) => {
const actual = await withDom(
'https://www.amazon.com/s?k=security+camera&page=1',
html,
collectAmazonNextPageUrl,
);
if (actual !== expected) throw new Error(`${name}: expected ${expected}, received ${actual}`);
};
const testCurrentPage = async (name: string, url: string, html: string, expected: number) => {
const actual = await withDom(url, html, collectAmazonCurrentPage);
if (actual !== expected) throw new Error(`${name}: expected ${expected}, received ${actual}`);
};
const main = async () => { const main = async () => {
await test( await testNextPage(
'active next page', 'active next page',
'<a class="s-pagination-next" href="/s?k=security+camera&page=2">Next</a>', '<a class="s-pagination-next" href="/s?k=security+camera&page=2">Next</a>',
'https://www.amazon.com/s?k=security+camera&page=2', 'https://www.amazon.com/s?k=security+camera&page=2',
); );
await test( await testNextPage(
'disabled next page', 'disabled next page',
'<span class="s-pagination-next s-pagination-disabled">Next</span>', '<span class="s-pagination-next s-pagination-disabled">Next</span>',
null, null,
); );
await test('missing next page', '<main>No pagination</main>', null); await testNextPage('missing next page', '<main>No pagination</main>', null);
await testCurrentPage(
'URL page wins over stale pagination selection',
'https://www.amazon.com/s?k=home+security&page=11&ref=sr_pg_11',
'<span class="s-pagination-item s-pagination-selected">12</span>',
11,
);
for (const page of [1, 2, 5, 10, 20]) {
await testCurrentPage(
`URL page ${page}`,
`https://www.amazon.com/s?k=home+security&page=${page}&ref=sr_pg_${page}`,
`<span class="s-pagination-item s-pagination-selected">${page + 1}</span>`,
page,
);
}
await testCurrentPage(
'invalid URL page uses ref fallback',
'https://www.amazon.com/s?k=home+security&page=invalid&ref=sr_pg_9',
'<span class="s-pagination-item s-pagination-selected">10</span>',
9,
);
await testCurrentPage(
'ref page fallback',
'https://www.amazon.com/s?k=home+security&ref=sr_pg_7',
'<main>No selected pagination item</main>',
7,
);
await testCurrentPage(
'selected pagination fallback',
'https://www.amazon.com/s?k=home+security',
'<span class="s-pagination-item s-pagination-selected">5</span>',
5,
);
await testCurrentPage(
'first page fallback',
'https://www.amazon.com/s?k=home+security',
'<main>No pagination</main>',
1,
);
console.log('Amazon search pagination tests passed.'); console.log('Amazon search pagination tests passed.');
}; };

View File

@ -1,3 +1,27 @@
/**
* Resolves the page being collected. Amazon's selected pagination element can
* point at the next page while the current results are still rendered, so the
* document URL is the authoritative source whenever it carries a page number.
*/
export async function collectAmazonCurrentPage(): Promise<number> {
const currentUrl = new URL(window.location.href);
const parsePage = (value: string | null | undefined) => {
const page = Number.parseInt(value || '', 10);
return Number.isInteger(page) && page > 0 ? page : null;
};
const urlPage = parsePage(currentUrl.searchParams.get('page'));
if (urlPage) return urlPage;
const refPage = parsePage(
/(?:^|_)pg_(\d+)(?:_|$)/.exec(currentUrl.searchParams.get('ref') || '')?.[1],
);
if (refPage) return refPage;
const selected = document.querySelector<HTMLElement>('.s-pagination-item.s-pagination-selected');
return parsePage(selected?.textContent?.trim()) || 1;
}
/** /**
* Reads the next search-page URL without navigating from the injected script. * Reads the next search-page URL without navigating from the injected script.
* Navigation must happen only after browser.scripting.executeScript has returned, * Navigation must happen only after browser.scripting.executeScript has returned,

View File

@ -2,12 +2,9 @@ import { BaseInjector } from './base';
import { collectAmazonSearchPageData, type AmazonSearchPagePattern } from './amazon-search-parser'; import { collectAmazonSearchPageData, type AmazonSearchPagePattern } from './amazon-search-parser';
import { collectAmazonDetailImageUrls } from './amazon-detail-images'; import { collectAmazonDetailImageUrls } from './amazon-detail-images';
import { waitForAmazonDetailPageReady } from './amazon-detail-page-ready'; import { waitForAmazonDetailPageReady } from './amazon-detail-page-ready';
import { collectAmazonNextPageUrl } from './amazon-search-pagination'; import { collectAmazonCurrentPage, collectAmazonNextPageUrl } from './amazon-search-pagination';
import { waitForAmazonSearchPageReady } from './amazon-search-page-ready'; import { waitForAmazonSearchPageReady } from './amazon-search-page-ready';
import { import { collectAmazonNextReviewPageUrl, waitForAmazonReviewPageReady } from './amazon-review-page';
collectAmazonNextReviewPageUrl,
waitForAmazonReviewPageReady,
} from './amazon-review-page';
export class AmazonSearchPageInjector extends BaseInjector { export class AmazonSearchPageInjector extends BaseInjector {
public waitForPageLoaded() { public waitForPageLoaded() {
@ -38,10 +35,7 @@ export class AmazonSearchPageInjector extends BaseInjector {
} }
public async getCurrentPage() { public async getCurrentPage() {
return this.run(async () => { return this.run(collectAmazonCurrentPage);
const node = document.querySelector<HTMLElement>('.s-pagination-item.s-pagination-selected');
return node ? Number(node.innerText) : 1;
});
} }
public async getNextPageUrl() { public async getNextPageUrl() {