@cosmocoder/mcp-web-docs 2.1.10 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/crawler/browser-config.js +3 -2
- package/build/crawler/browser-config.js.map +1 -1
- package/build/crawler/crawlee-crawler.d.ts +15 -9
- package/build/crawler/crawlee-crawler.js +106 -69
- package/build/crawler/crawlee-crawler.js.map +1 -1
- package/build/crawler/crawlee-crawler.test.js +279 -101
- package/build/crawler/crawlee-crawler.test.js.map +1 -1
- package/build/crawler/docs-crawler.d.ts +3 -0
- package/build/crawler/docs-crawler.js +7 -0
- package/build/crawler/docs-crawler.js.map +1 -1
- package/build/crawler/docs-crawler.test.js +37 -0
- package/build/crawler/docs-crawler.test.js.map +1 -1
- package/build/crawler/login-page-signals.d.ts +6 -7
- package/build/crawler/login-page-signals.js +4 -16
- package/build/crawler/login-page-signals.js.map +1 -1
- package/build/crawler/login-page-signals.test.js +13 -32
- package/build/crawler/login-page-signals.test.js.map +1 -1
- package/build/crawler/queue-manager.d.ts +2 -2
- package/build/crawler/queue-manager.js +5 -4
- package/build/crawler/queue-manager.js.map +1 -1
- package/build/crawler/queue-manager.test.js +27 -41
- package/build/crawler/queue-manager.test.js.map +1 -1
- package/build/crawler/site-rules.d.ts +1 -2
- package/build/crawler/site-rules.js +3 -2
- package/build/crawler/site-rules.js.map +1 -1
- package/build/crawler/site-rules.test.js +3 -9
- package/build/crawler/site-rules.test.js.map +1 -1
- package/build/index.test.js +40 -4
- package/build/index.test.js.map +1 -1
- package/build/indexing/status.d.ts +1 -7
- package/build/indexing/status.js +38 -3
- package/build/indexing/status.js.map +1 -1
- package/build/indexing/status.test.js +69 -0
- package/build/indexing/status.test.js.map +1 -1
- package/build/indexing/workflow.d.ts +1 -1
- package/build/indexing/workflow.js +27 -7
- package/build/indexing/workflow.js.map +1 -1
- package/build/indexing/workflow.test.js +53 -6
- package/build/indexing/workflow.test.js.map +1 -1
- package/build/server.js +10 -2
- package/build/server.js.map +1 -1
- package/build/server.test.js +19 -0
- package/build/server.test.js.map +1 -1
- package/build/types.d.ts +4 -0
- package/build/util/security.d.ts +13 -3
- package/build/util/security.js +38 -6
- package/build/util/security.js.map +1 -1
- package/build/util/security.test.js +28 -4
- package/build/util/security.test.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { LoginPageServedError } from '../util/security.js';
|
|
2
2
|
const mockQueueManager = {
|
|
3
3
|
initialize: vi.fn().mockResolvedValue(undefined),
|
|
4
4
|
seedFromLlmsTxt: vi.fn().mockResolvedValue(0),
|
|
@@ -106,13 +106,12 @@ function navigationPage(url, onLoad, evaluate = vi.fn().mockResolvedValue(false)
|
|
|
106
106
|
};
|
|
107
107
|
return { page, listeners, mainFrame };
|
|
108
108
|
}
|
|
109
|
-
async function runRequestHandler(page, url, initialResponse = response()
|
|
109
|
+
async function runRequestHandler(page, url, initialResponse = response()) {
|
|
110
110
|
await getRequestHandler()({
|
|
111
111
|
request: { url },
|
|
112
112
|
response: initialResponse,
|
|
113
113
|
page,
|
|
114
114
|
enqueueLinks: vi.fn(),
|
|
115
|
-
log: { debug: vi.fn(), error: vi.fn(), warning },
|
|
116
115
|
});
|
|
117
116
|
}
|
|
118
117
|
async function emitPreNavigationFailure(request, failedUrl) {
|
|
@@ -247,45 +246,43 @@ describe('CrawleeCrawler', () => {
|
|
|
247
246
|
on: vi.fn(),
|
|
248
247
|
off: vi.fn(),
|
|
249
248
|
};
|
|
250
|
-
const warning = vi.fn();
|
|
251
249
|
mockCrawlerRun.mockImplementationOnce(async () => {
|
|
252
|
-
await runRequestHandler(page, 'https://example.com/private', response(403, true)
|
|
250
|
+
await runRequestHandler(page, 'https://example.com/private?session=abc123', response(403, true));
|
|
253
251
|
});
|
|
254
252
|
await collect(crawler, 'https://example.com/private');
|
|
255
|
-
|
|
253
|
+
// The logger's own redaction leaves session, sid and passwd values alone, so the URL is
|
|
254
|
+
// redacted before it gets there
|
|
255
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('blocked outbound destination: https://example.com/private?session=[REDACTED]'));
|
|
256
256
|
expect(page.waitForLoadState).not.toHaveBeenCalled();
|
|
257
257
|
expect(mockQueueManager.addResult).not.toHaveBeenCalled();
|
|
258
258
|
});
|
|
259
259
|
it('does not index a client-side navigation blocked after the initial response', async () => {
|
|
260
260
|
const { page, listeners } = navigationPage('https://example.com/docs', (emit) => emit.response(403, true));
|
|
261
|
-
const warning = vi.fn();
|
|
262
261
|
mockCrawlerRun.mockImplementationOnce(async () => {
|
|
263
|
-
await runRequestHandler(page, 'https://example.com/docs', response()
|
|
262
|
+
await runRequestHandler(page, 'https://example.com/docs', response());
|
|
264
263
|
});
|
|
265
264
|
await collect(crawler, 'https://example.com/docs');
|
|
266
|
-
expect(
|
|
265
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('blocked outbound destination'));
|
|
267
266
|
expect(mockQueueManager.addResult).not.toHaveBeenCalled();
|
|
268
267
|
expect(page.off).toHaveBeenCalledWith('response', listeners.response);
|
|
269
268
|
});
|
|
270
269
|
it('rethrows a later main-frame navigation failure for Crawlee to retry', async () => {
|
|
271
270
|
const { page, listeners } = navigationPage('https://8.8.8.8/docs', (emit) => emit.failure('net::ERR_CONNECTION_REFUSED', 'https://8.8.8.8/docs'));
|
|
272
|
-
const warning = vi.fn();
|
|
273
271
|
mockCrawlerRun.mockImplementationOnce(async () => {
|
|
274
|
-
await runRequestHandler(page, 'https://8.8.8.8/docs', response()
|
|
272
|
+
await runRequestHandler(page, 'https://8.8.8.8/docs', response());
|
|
275
273
|
});
|
|
276
274
|
await expect(collect(crawler, 'https://8.8.8.8/docs')).rejects.toThrow('Outbound destination unavailable');
|
|
277
|
-
expect(
|
|
275
|
+
expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('blocked outbound destination'));
|
|
278
276
|
expect(mockQueueManager.addResult).not.toHaveBeenCalled();
|
|
279
277
|
expect(page.off).toHaveBeenCalledWith('requestfailed', listeners.requestfailed);
|
|
280
278
|
});
|
|
281
279
|
it('treats a policy-blocked later navigation as handled without retry', async () => {
|
|
282
280
|
const { page } = navigationPage('https://example.com/docs', (emit) => emit.failure('net::ERR_TUNNEL_CONNECTION_FAILED', 'http://127.0.0.1/docs'));
|
|
283
|
-
const warning = vi.fn();
|
|
284
281
|
mockCrawlerRun.mockImplementationOnce(async () => {
|
|
285
|
-
await runRequestHandler(page, 'https://example.com/docs', response()
|
|
282
|
+
await runRequestHandler(page, 'https://example.com/docs', response());
|
|
286
283
|
});
|
|
287
284
|
await collect(crawler, 'https://example.com/docs');
|
|
288
|
-
expect(
|
|
285
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('blocked outbound destination'));
|
|
289
286
|
expect(mockQueueManager.addResult).not.toHaveBeenCalled();
|
|
290
287
|
});
|
|
291
288
|
it('extracts a later successful page without altering Markdown after superseded navigation failures', async () => {
|
|
@@ -392,6 +389,24 @@ describe('CrawleeCrawler', () => {
|
|
|
392
389
|
await collect(crawler, 'https://example.com/docs');
|
|
393
390
|
expect(mockQueueManager.addResult).toHaveBeenCalledTimes(allowed ? 1 : 0);
|
|
394
391
|
});
|
|
392
|
+
// The entry page leaving the site is where a crawl ends with nothing, so the reason has to reach
|
|
393
|
+
// stderr. Which reason it is depends on whether there was a session to lose.
|
|
394
|
+
it.each([
|
|
395
|
+
['a crawl with a session blames the session', true, /Session expired - redirected to external domain/],
|
|
396
|
+
['a crawl without one does not', false, /First page redirected to external domain/],
|
|
397
|
+
])('%s when the entry page redirects off the site', async (_label, authenticated, expected) => {
|
|
398
|
+
if (authenticated) {
|
|
399
|
+
crawler.setStorageState({ cookies: [] });
|
|
400
|
+
}
|
|
401
|
+
const { page } = navigationPage('https://example.net/login', () => { });
|
|
402
|
+
mockCrawlerRun.mockImplementationOnce(async () => {
|
|
403
|
+
await runRequestHandler(page, 'https://example.com/docs');
|
|
404
|
+
return successfulRunStats;
|
|
405
|
+
});
|
|
406
|
+
// Only the authenticated branch has an error to throw; both abort, and both have to say why
|
|
407
|
+
await collect(crawler, 'https://example.com/docs').catch(() => undefined);
|
|
408
|
+
expect(logger.error).toHaveBeenCalledWith(expect.stringMatching(expected));
|
|
409
|
+
});
|
|
395
410
|
});
|
|
396
411
|
describe('session expiry', () => {
|
|
397
412
|
// Four indicators of the detector's six, so it clears the confidence bar on content alone
|
|
@@ -407,7 +422,8 @@ describe('CrawleeCrawler', () => {
|
|
|
407
422
|
: false));
|
|
408
423
|
return Object.assign(page, { content: vi.fn().mockResolvedValue(`<html><body>${bodyText}${html}</body></html>`) });
|
|
409
424
|
}
|
|
410
|
-
//
|
|
425
|
+
// Most of what the check decides turns on a session, so every case here starts from one. The cases
|
|
426
|
+
// that are about a crawl without one replace the crawler.
|
|
411
427
|
beforeEach(() => {
|
|
412
428
|
crawler.setStorageState({ cookies: [{ name: 'auth', value: 'token', domain: 'example.com', path: '/' }] });
|
|
413
429
|
});
|
|
@@ -425,15 +441,44 @@ describe('CrawleeCrawler', () => {
|
|
|
425
441
|
bodyText: `Ordinary content about topic ${n}`,
|
|
426
442
|
headings: [`Topic ${n}`],
|
|
427
443
|
});
|
|
428
|
-
//
|
|
444
|
+
// The furniture of a documentation site - nav, sidebar, footer - which a login page served inside
|
|
445
|
+
// that shell carries too. Long enough that the page is not bare, which is a separate rule.
|
|
446
|
+
const SHELL_TEXT = 'Guides Reference Tutorials API Changelog Support Status Community Careers Legal Privacy Terms. '.repeat(3);
|
|
447
|
+
// One login page, whatever URL it was asked for. Served inside the site's own shell, so only its
|
|
448
|
+
// repetition gives it away - a bare wall is caught on sight instead, by loginWall below.
|
|
429
449
|
const loginPage = (n) => ({
|
|
450
|
+
url: `https://example.com/docs/${n}`,
|
|
451
|
+
bodyText: `${LOGIN_BODY} ${SHELL_TEXT}`,
|
|
452
|
+
headings: ['Sign in'],
|
|
453
|
+
asksForPassword: true,
|
|
454
|
+
});
|
|
455
|
+
// Nothing but the form
|
|
456
|
+
const loginWall = (n) => ({
|
|
430
457
|
url: `https://example.com/docs/${n}`,
|
|
431
458
|
bodyText: LOGIN_BODY,
|
|
432
459
|
headings: ['Sign in'],
|
|
433
460
|
asksForPassword: true,
|
|
434
461
|
});
|
|
435
|
-
|
|
436
|
-
|
|
462
|
+
// The same wall answering a path of its own, where a test needs several walls that are not
|
|
463
|
+
// documentation URLs - a /login and a /signup rather than a /docs/2
|
|
464
|
+
const wallAt = (path) => ({ ...loginWall(0), url: `https://example.com/${path}` });
|
|
465
|
+
// A login page where the crawl asked for documentation fails it on sight, in whatever shape it
|
|
466
|
+
// arrives, and says which page that was rather than counting it as one wall among many.
|
|
467
|
+
it.each([
|
|
468
|
+
['wording alone', { url: 'https://example.com/docs', bodyText: LOGIN_BODY }],
|
|
469
|
+
['nothing but the form', { ...loginWall(1), url: 'https://example.com/docs' }],
|
|
470
|
+
// No wording and no password field: the markup is the only evidence, and reading the markup is
|
|
471
|
+
// what the crawl skips for a page showing no sign of a login
|
|
472
|
+
[
|
|
473
|
+
'a wordless SSO shell',
|
|
474
|
+
{
|
|
475
|
+
url: 'https://example.com/docs',
|
|
476
|
+
bodyText: 'Redirecting',
|
|
477
|
+
html: '<form action="/login"><input name="username"><img alt="Continue with Okta">',
|
|
478
|
+
},
|
|
479
|
+
],
|
|
480
|
+
])('fails an authenticated crawl whose entry page is a login page by %s', async (_label, entry) => {
|
|
481
|
+
await expect(crawlPages([entry])).rejects.toThrow(/the page the crawl was asked for is a login page/i);
|
|
437
482
|
});
|
|
438
483
|
// A URL match is worth three of the detector's six indicators, so scoring the URL would make
|
|
439
484
|
// every page of this host a login page and no authenticated crawl of it possible. The wording
|
|
@@ -447,26 +492,26 @@ describe('CrawleeCrawler', () => {
|
|
|
447
492
|
await expect(crawlPages(pages, 'https://auth.example.com/docs')).resolves.toBeUndefined();
|
|
448
493
|
expect(mockQueueManager.addResult).toHaveBeenCalledTimes(3);
|
|
449
494
|
});
|
|
450
|
-
//
|
|
451
|
-
it.
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
//
|
|
462
|
-
it('
|
|
463
|
-
|
|
464
|
-
|
|
495
|
+
// A run of walls among the pages the crawl can currently see is what a dead session looks like.
|
|
496
|
+
// It stops there rather than at the end, so the pages after it are never fetched.
|
|
497
|
+
it('stops the crawl once walls are most of what it is seeing', async () => {
|
|
498
|
+
await expect(crawlPages([...[1, 2, 3].map(docsPage), ...[4, 5, 6, 7, 8].map(loginWall)])).rejects.toThrow(/5 of the last 8 pages the crawl saw were login walls/i);
|
|
499
|
+
expect(mockQueueManager.addResult).toHaveBeenCalledTimes(3);
|
|
500
|
+
});
|
|
501
|
+
// Too short for the window to fill, but walls were still most of it
|
|
502
|
+
it('fails a short crawl that was mostly login walls', async () => {
|
|
503
|
+
await expect(crawlPages([docsPage(1), loginWall(2), loginWall(3)])).rejects.toThrow(/2 of the 3 pages the crawl saw were login walls/i);
|
|
504
|
+
});
|
|
505
|
+
// The accepted limit of the window: a session dying with only a few pages left is not caught. The
|
|
506
|
+
// walls are still kept out of the index, so nothing wrong is stored - those pages are just absent.
|
|
507
|
+
it('does not stop a long crawl whose session died in its last few pages', async () => {
|
|
508
|
+
await expect(crawlPages([...Array.from({ length: 10 }, (_, i) => docsPage(i + 1)), ...[11, 12, 13, 14].map(loginWall)])).resolves.toBeUndefined();
|
|
509
|
+
expect(mockQueueManager.addResult).toHaveBeenCalledTimes(10);
|
|
465
510
|
});
|
|
466
|
-
// Too small a share of the crawl for the after-the-fact rule; only the
|
|
511
|
+
// Too small a share of the crawl for the after-the-fact rule; only the window catches it
|
|
467
512
|
it('fails a long crawl where the session died near the end', async () => {
|
|
468
|
-
const tail = [1, 2, 3].map((n) => ({ ...
|
|
469
|
-
await expect(crawlPages([...[1, 2, 3, 4, 5, 6, 7, 8].map(docsPage), ...tail])).rejects.toThrow(/
|
|
513
|
+
const tail = [1, 2, 3, 4, 5].map((n) => ({ ...loginWall(n), url: `https://example.com/docs/late-${n}` }));
|
|
514
|
+
await expect(crawlPages([...[1, 2, 3, 4, 5, 6, 7, 8].map(docsPage), ...tail])).rejects.toThrow(/5 of the last 8 pages the crawl saw were login walls/i);
|
|
470
515
|
});
|
|
471
516
|
// One branded button, with the only real evidence in the markup
|
|
472
517
|
it.each([
|
|
@@ -474,16 +519,25 @@ describe('CrawleeCrawler', () => {
|
|
|
474
519
|
['an almost wordless login shell', 'Continue', '<iframe src="/idp"></iframe><input type="password">'],
|
|
475
520
|
])('detects %s', async (_label, bodyText, html) => {
|
|
476
521
|
const pages = [1, 2, 3].map((n) => ({ url: `https://example.com/docs/${n}`, bodyText, html }));
|
|
477
|
-
await expect(crawlPages([docsPage(0), ...pages])).rejects.toThrow(
|
|
522
|
+
await expect(crawlPages([docsPage(0), ...pages])).rejects.toThrow(LoginPageServedError);
|
|
478
523
|
});
|
|
479
|
-
//
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
524
|
+
// What a documentation site does that reads like a login page. Both clear the detector's own bar;
|
|
525
|
+
// what keeps them in the index is the wall rule, which needs a page with only the form on it.
|
|
526
|
+
it.each([
|
|
527
|
+
// A sign-in box in the site chrome, on every page
|
|
528
|
+
[
|
|
529
|
+
'carry a sign-in box in an article',
|
|
530
|
+
[1, 2, 3].map((n) => {
|
|
531
|
+
const served = loginPage(n);
|
|
532
|
+
return { ...served, bodyText: `${served.bodyText} Topic ${n}`, headings: [`Topic ${n}`] };
|
|
533
|
+
}),
|
|
534
|
+
],
|
|
535
|
+
// Bare, with a password box, but tripping only two of the detector's six indicators - short of the
|
|
536
|
+
// bar, and so not the crawl's business to judge. The accepted cost is a wall worded like this one.
|
|
537
|
+
["trip only two of the detector's indicators", [{ ...loginWall(1), bodyText: 'Sign in Username' }]],
|
|
538
|
+
])('keeps crawling documentation pages that %s', async (_label, pages) => {
|
|
539
|
+
await expect(crawlPages([docsPage(0), ...pages])).resolves.toBeUndefined();
|
|
540
|
+
expect(mockQueueManager.addResult).toHaveBeenCalledTimes(pages.length + 1);
|
|
487
541
|
});
|
|
488
542
|
// By arrival order, one ordinary page reading as a login page would fail the crawl on its own,
|
|
489
543
|
// with none of the repetition the rule is built on
|
|
@@ -493,12 +547,6 @@ describe('CrawleeCrawler', () => {
|
|
|
493
547
|
{ url: 'https://example.com/docs', bodyText: 'Ordinary content on the page the crawl asked for' },
|
|
494
548
|
])).resolves.toBeUndefined();
|
|
495
549
|
});
|
|
496
|
-
// A login page's wording varies per request - a return parameter, a csrf token, an attempt
|
|
497
|
-
// counter - so the identity cannot rest on it. The form it puts in front of you does not vary.
|
|
498
|
-
it('fails the crawl when the login page varies by the text it carries', async () => {
|
|
499
|
-
const pages = [1, 2, 3].map((n) => ({ ...loginPage(n), bodyText: `${LOGIN_BODY} next=%2Fdocs%2F${n} token=a${n}f9c${n}` }));
|
|
500
|
-
await expect(crawlPages([docsPage(0), ...pages])).rejects.toThrow(/one login page answered 3 different URLs/i);
|
|
501
|
-
});
|
|
502
550
|
it('does not serialize the markup of a page with no sign of a login', async () => {
|
|
503
551
|
const ordinary = authenticatedPage(docsPage(1));
|
|
504
552
|
mockCrawlerRun.mockImplementationOnce(async () => {
|
|
@@ -508,60 +556,190 @@ describe('CrawleeCrawler', () => {
|
|
|
508
556
|
await collect(crawler, 'https://example.com/docs');
|
|
509
557
|
expect(ordinary.content).not.toHaveBeenCalled();
|
|
510
558
|
});
|
|
511
|
-
//
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
'a
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
}));
|
|
536
|
-
await expect(crawlPages([docsPage(0), ...pages])).resolves.toBeUndefined();
|
|
537
|
-
expect(mockQueueManager.addResult).toHaveBeenCalledTimes(4);
|
|
559
|
+
// A first attempt that cannot read the page still records it. Left in that older position, the wall
|
|
560
|
+
// can sit outside every window that follows, and a run of them slips both rules.
|
|
561
|
+
it('moves a wall to where the crawl found it when an earlier attempt could not be read', async () => {
|
|
562
|
+
const unreadable = () => {
|
|
563
|
+
const { page } = navigationPage(wallAt('a').url, () => { }, vi.fn(async (fn) => {
|
|
564
|
+
if (fn === readLoginPageSignals) {
|
|
565
|
+
throw new Error('execution context destroyed');
|
|
566
|
+
}
|
|
567
|
+
return false;
|
|
568
|
+
}));
|
|
569
|
+
return Object.assign(page, { content: vi.fn().mockResolvedValue('') });
|
|
570
|
+
};
|
|
571
|
+
mockCrawlerRun.mockImplementationOnce(async () => {
|
|
572
|
+
await runRequestHandler(unreadable(), wallAt('a').url);
|
|
573
|
+
for (const n of [1, 2, 3, 4, 5, 6, 7]) {
|
|
574
|
+
await runRequestHandler(authenticatedPage(docsPage(n)), docsPage(n).url);
|
|
575
|
+
}
|
|
576
|
+
await runRequestHandler(authenticatedPage(wallAt('a')), wallAt('a').url);
|
|
577
|
+
for (const slug of ['b', 'c', 'd', 'e']) {
|
|
578
|
+
await runRequestHandler(authenticatedPage(wallAt(slug)), wallAt(slug).url);
|
|
579
|
+
}
|
|
580
|
+
return successfulRunStats;
|
|
581
|
+
});
|
|
582
|
+
await expect(collect(crawler, 'https://example.com/docs')).rejects.toThrow(/5 of the last 8 pages the crawl saw were login walls/i);
|
|
538
583
|
});
|
|
539
|
-
//
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
584
|
+
// One URL fetched twice can come back bare once and inside the shell once - a slow theme, a retry -
|
|
585
|
+
// and the second look must not un-learn the wall. Long enough that only the window can end it.
|
|
586
|
+
it('keeps a wall recorded when a later look at the same URL is not one', async () => {
|
|
587
|
+
const wallN = (n) => wallAt(`wall-${n}`);
|
|
588
|
+
mockCrawlerRun.mockImplementationOnce(async () => {
|
|
589
|
+
for (const n of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) {
|
|
590
|
+
await runRequestHandler(authenticatedPage(docsPage(n)), docsPage(n).url);
|
|
591
|
+
}
|
|
592
|
+
for (const n of [1, 2, 3, 4]) {
|
|
593
|
+
await runRequestHandler(authenticatedPage(wallN(n)), wallN(n).url);
|
|
594
|
+
}
|
|
595
|
+
// The same URL again, this time rendered inside the site's shell rather than bare
|
|
596
|
+
await runRequestHandler(authenticatedPage({ ...loginPage(1), url: wallN(1).url }), wallN(1).url);
|
|
597
|
+
await runRequestHandler(authenticatedPage(wallN(5)), wallN(5).url);
|
|
598
|
+
return successfulRunStats;
|
|
599
|
+
});
|
|
600
|
+
await expect(collect(crawler, 'https://example.com/docs')).rejects.toThrow(/5 of the last 8 pages the crawl saw were login walls/i);
|
|
546
601
|
});
|
|
547
|
-
//
|
|
548
|
-
it('
|
|
549
|
-
|
|
602
|
+
// The crawl retries a failed page, and the wall it retries is not a second URL
|
|
603
|
+
it('does not count one wall URL twice when it is handled again', async () => {
|
|
604
|
+
const wall = loginWall(2);
|
|
605
|
+
mockCrawlerRun.mockImplementationOnce(async () => {
|
|
606
|
+
await runRequestHandler(authenticatedPage(docsPage(1)), docsPage(1).url);
|
|
607
|
+
await runRequestHandler(authenticatedPage(wall), wall.url);
|
|
608
|
+
await runRequestHandler(authenticatedPage(wall), wall.url);
|
|
609
|
+
return successfulRunStats;
|
|
610
|
+
});
|
|
611
|
+
await expect(collect(crawler, 'https://example.com/docs')).resolves.toBeDefined();
|
|
612
|
+
});
|
|
613
|
+
// Two crawls on one instance: the wall the first one skipped must not be the first of two for the
|
|
614
|
+
// second, or a site crawled twice fails the second time for what the first one saw
|
|
615
|
+
it('starts each crawl from no login pages at all', async () => {
|
|
616
|
+
await crawlPages([docsPage(1), loginWall(2)]);
|
|
617
|
+
vi.mocked(logger.warn).mockClear();
|
|
618
|
+
await expect(crawlPages([docsPage(1), loginWall(3)])).resolves.toBeUndefined();
|
|
619
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Left 1 login page out of the index'));
|
|
620
|
+
});
|
|
621
|
+
// The page dropped from the index has to be named somewhere, or neither remedy can be applied to
|
|
622
|
+
// it, and it is normalized to match the key the window counts by.
|
|
623
|
+
it('reports the skipped URL redacted and normalized', async () => {
|
|
624
|
+
const wall = { ...loginWall(2), url: 'https://example.com/docs/2?token=abc123&next=%2Fdocs#top' };
|
|
625
|
+
await crawlPages([docsPage(1), wall]);
|
|
626
|
+
expect(crawler.skippedLoginPageUrls).toEqual(['https://example.com/docs/2?token=[REDACTED]&next=%2Fdocs']);
|
|
627
|
+
// The logger's own redaction covers fewer parameter names than this one, so logging the raw URL
|
|
628
|
+
// would echo what the report withheld
|
|
629
|
+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Not indexing https://example.com/docs/2?token=[REDACTED]'));
|
|
630
|
+
expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('abc123'));
|
|
631
|
+
});
|
|
632
|
+
// The caller must not be able to reach into the crawl's own state through what it reads
|
|
633
|
+
it('hands out a copy of the skipped URLs', async () => {
|
|
634
|
+
await crawlPages([docsPage(1), loginWall(2)]);
|
|
635
|
+
crawler.skippedLoginPageUrls.push('https://example.com/injected');
|
|
636
|
+
expect(crawler.skippedLoginPageUrls).toEqual(['https://example.com/docs/2']);
|
|
637
|
+
});
|
|
638
|
+
// The links of a skipped page are still wanted: a sparse section index is exactly the page most
|
|
639
|
+
// likely to be mistaken for a wall, and its whole section hangs off it
|
|
640
|
+
it('still follows the links of a login page it left out', async () => {
|
|
641
|
+
await crawlPages([docsPage(1), loginWall(2), docsPage(3)]);
|
|
642
|
+
expect(mockQueueManager.handleQueueAndLinks).toHaveBeenCalledTimes(3);
|
|
643
|
+
});
|
|
644
|
+
// A public crawl is not failed over walls it found, so a site that gave up nothing else ends with
|
|
645
|
+
// nothing to index. The workflow fails the operation over that, counting the walls and naming one.
|
|
646
|
+
it('leaves a public site of nothing but login walls with no pages and every wall reported', async () => {
|
|
647
|
+
crawler = new CrawleeCrawler();
|
|
648
|
+
await expect(crawlPages([loginWall(1), loginWall(2)])).resolves.toBeUndefined();
|
|
649
|
+
expect(mockQueueManager.addResult).not.toHaveBeenCalled();
|
|
650
|
+
expect(crawler.skippedLoginPageUrls).toEqual(['https://example.com/docs/1', 'https://example.com/docs/2']);
|
|
550
651
|
});
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
it('
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
html: '<form action="/login"><input name="username"><img alt="Continue with Okta"></form>',
|
|
558
|
-
};
|
|
559
|
-
await expect(crawlPages([wall])).rejects.toThrow(SessionExpiredError);
|
|
652
|
+
// Walls being half a small site is a public site with a /login and a /signup in its nav, not a dead
|
|
653
|
+
// session. Failing would throw away the documentation the crawl did index.
|
|
654
|
+
it('keeps a public site whose login pages are half of a short crawl', async () => {
|
|
655
|
+
crawler = new CrawleeCrawler();
|
|
656
|
+
await expect(crawlPages([docsPage(1), docsPage(2), wallAt('login'), wallAt('signup')])).resolves.toBeUndefined();
|
|
657
|
+
expect(mockQueueManager.addResult).toHaveBeenCalledTimes(2);
|
|
560
658
|
});
|
|
561
|
-
// The
|
|
562
|
-
|
|
659
|
+
// The nav enqueues its links together, so gated areas arrive as a run - here one long enough to fill
|
|
660
|
+
// the window on its own, which puts what the crawl indexed outside it. The whole crawl answers.
|
|
661
|
+
it('keeps a public site whose gated areas arrive as a run of login walls', async () => {
|
|
563
662
|
crawler = new CrawleeCrawler();
|
|
564
|
-
|
|
663
|
+
const walls = ['login', 'signup', 'admin/login', 'portal/login', 'legacy/login', 'shop/login', 'crm/login', 'wiki/login'];
|
|
664
|
+
await expect(crawlPages([...[1, 2, 3].map(docsPage), ...walls.map(wallAt)])).resolves.toBeUndefined();
|
|
665
|
+
expect(mockQueueManager.addResult).toHaveBeenCalledTimes(3);
|
|
666
|
+
expect(crawler.skippedLoginPageUrls).toHaveLength(8);
|
|
667
|
+
});
|
|
668
|
+
// Five pages are in flight at once and a page is extracted after its links are enqueued, so a run of
|
|
669
|
+
// walls can finish while accepted documentation is still being extracted.
|
|
670
|
+
it('keeps a public crawl whose documentation is still being extracted when a run of walls arrives', async () => {
|
|
671
|
+
crawler = new CrawleeCrawler();
|
|
672
|
+
const extraction = Promise.withResolvers();
|
|
673
|
+
const reachedExtraction = Promise.withResolvers();
|
|
674
|
+
const entry = Object.assign(authenticatedPage({ url: 'https://example.com/docs', bodyText: 'Ordinary content' }), {
|
|
675
|
+
title: vi.fn(async () => {
|
|
676
|
+
reachedExtraction.resolve();
|
|
677
|
+
await extraction.promise;
|
|
678
|
+
return 'Docs';
|
|
679
|
+
}),
|
|
680
|
+
});
|
|
681
|
+
mockCrawlerRun.mockImplementationOnce(async () => {
|
|
682
|
+
const inFlight = runRequestHandler(entry, 'https://example.com/docs');
|
|
683
|
+
await reachedExtraction.promise;
|
|
684
|
+
for (const path of ['login', 'signup', 'admin/login', 'portal/login', 'legacy/login']) {
|
|
685
|
+
await runRequestHandler(authenticatedPage(wallAt(path)), `https://example.com/${path}`);
|
|
686
|
+
}
|
|
687
|
+
extraction.resolve();
|
|
688
|
+
await inFlight;
|
|
689
|
+
return successfulRunStats;
|
|
690
|
+
});
|
|
691
|
+
await expect(collect(crawler, 'https://example.com/docs')).resolves.toBeDefined();
|
|
692
|
+
expect(mockQueueManager.addResult).toHaveBeenCalledTimes(1);
|
|
693
|
+
expect(crawler.skippedLoginPageUrls).toHaveLength(5);
|
|
694
|
+
});
|
|
695
|
+
// A session that died is a different matter: the pages before it are stale, so the crawl fails even
|
|
696
|
+
// though it indexed some. Exactly half has to count, or a session dying at the midpoint of a short
|
|
697
|
+
// crawl passes. Interleaved, because where the walls fall makes no difference to this rule.
|
|
698
|
+
it('stops an authenticated crawl whose login walls are half of it', async () => {
|
|
699
|
+
await expect(crawlPages([docsPage(1), wallAt('a'), docsPage(2), wallAt('b')])).rejects.toThrow(/Authentication session expired during the crawl - 2 of the 4 pages the crawl saw were login walls/i);
|
|
700
|
+
});
|
|
701
|
+
it('crawls an ordinary public site that was never authenticated', async () => {
|
|
702
|
+
crawler = new CrawleeCrawler();
|
|
703
|
+
await expect(crawlPages([1, 2, 3].map(docsPage))).resolves.toBeUndefined();
|
|
704
|
+
expect(mockQueueManager.addResult).toHaveBeenCalledTimes(3);
|
|
705
|
+
});
|
|
706
|
+
// Asking to index a site that turns out to need signing in - the likeliest way this is met, and
|
|
707
|
+
// the entry page is caught without a password field of its own
|
|
708
|
+
it('stops an unauthenticated crawl whose entry page is a login wall', async () => {
|
|
709
|
+
crawler = new CrawleeCrawler();
|
|
710
|
+
await expect(crawlPages([{ ...loginWall(1), url: 'https://example.com/docs' }])).rejects.toThrow(/This site requires authentication - the page the crawl was asked for is a login page/i);
|
|
711
|
+
});
|
|
712
|
+
// A public article about building login forms scores as a login page: a code sample containing
|
|
713
|
+
// type="password" is worth two of the detector's six indicators.
|
|
714
|
+
it('crawls a public site whose entry page documents authentication', async () => {
|
|
715
|
+
crawler = new CrawleeCrawler();
|
|
716
|
+
const article = {
|
|
717
|
+
url: 'https://example.com/docs',
|
|
718
|
+
bodyText: `Building a sign in form. ${'Explaining each field of the form at length. '.repeat(8)}`,
|
|
719
|
+
html: '<pre><input type="password" name="password"></pre>',
|
|
720
|
+
};
|
|
721
|
+
await expect(crawlPages([article, docsPage(2)])).resolves.toBeUndefined();
|
|
722
|
+
expect(mockQueueManager.addResult).toHaveBeenCalledTimes(2);
|
|
723
|
+
});
|
|
724
|
+
// A public site's own /login is a wall, and a crawl without a pathPrefix reaches it from the nav.
|
|
725
|
+
// Leaving it out of the index is the whole fix; stopping the crawl over it costs far more.
|
|
726
|
+
it('leaves a single login wall out of the index and keeps crawling', async () => {
|
|
727
|
+
await expect(crawlPages([docsPage(1), loginWall(2), docsPage(3)])).resolves.toBeUndefined();
|
|
728
|
+
expect(mockQueueManager.addResult).toHaveBeenCalledTimes(2);
|
|
729
|
+
});
|
|
730
|
+
// A wall reached by redirect answers a URL that is not its own, and each of those is a page the
|
|
731
|
+
// crawl lost. Counting the page's own address instead would make any number of them one page.
|
|
732
|
+
it("counts every URL a wall was served for, not the wall's own address", async () => {
|
|
733
|
+
mockCrawlerRun.mockImplementationOnce(async () => {
|
|
734
|
+
for (const n of [1, 2, 3]) {
|
|
735
|
+
await runRequestHandler(authenticatedPage(docsPage(n)), docsPage(n).url);
|
|
736
|
+
}
|
|
737
|
+
for (const n of [4, 5, 6, 7, 8]) {
|
|
738
|
+
await runRequestHandler(authenticatedPage(wallAt('login')), `https://example.com/docs/${n}`);
|
|
739
|
+
}
|
|
740
|
+
return successfulRunStats;
|
|
741
|
+
});
|
|
742
|
+
await expect(collect(crawler, 'https://example.com/docs')).rejects.toThrow(/were login walls/i);
|
|
565
743
|
});
|
|
566
744
|
});
|
|
567
745
|
describe('authentication', () => {
|