@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.
Files changed (50) hide show
  1. package/build/crawler/browser-config.js +3 -2
  2. package/build/crawler/browser-config.js.map +1 -1
  3. package/build/crawler/crawlee-crawler.d.ts +15 -9
  4. package/build/crawler/crawlee-crawler.js +106 -69
  5. package/build/crawler/crawlee-crawler.js.map +1 -1
  6. package/build/crawler/crawlee-crawler.test.js +279 -101
  7. package/build/crawler/crawlee-crawler.test.js.map +1 -1
  8. package/build/crawler/docs-crawler.d.ts +3 -0
  9. package/build/crawler/docs-crawler.js +7 -0
  10. package/build/crawler/docs-crawler.js.map +1 -1
  11. package/build/crawler/docs-crawler.test.js +37 -0
  12. package/build/crawler/docs-crawler.test.js.map +1 -1
  13. package/build/crawler/login-page-signals.d.ts +6 -7
  14. package/build/crawler/login-page-signals.js +4 -16
  15. package/build/crawler/login-page-signals.js.map +1 -1
  16. package/build/crawler/login-page-signals.test.js +13 -32
  17. package/build/crawler/login-page-signals.test.js.map +1 -1
  18. package/build/crawler/queue-manager.d.ts +2 -2
  19. package/build/crawler/queue-manager.js +5 -4
  20. package/build/crawler/queue-manager.js.map +1 -1
  21. package/build/crawler/queue-manager.test.js +27 -41
  22. package/build/crawler/queue-manager.test.js.map +1 -1
  23. package/build/crawler/site-rules.d.ts +1 -2
  24. package/build/crawler/site-rules.js +3 -2
  25. package/build/crawler/site-rules.js.map +1 -1
  26. package/build/crawler/site-rules.test.js +3 -9
  27. package/build/crawler/site-rules.test.js.map +1 -1
  28. package/build/index.test.js +40 -4
  29. package/build/index.test.js.map +1 -1
  30. package/build/indexing/status.d.ts +1 -7
  31. package/build/indexing/status.js +38 -3
  32. package/build/indexing/status.js.map +1 -1
  33. package/build/indexing/status.test.js +69 -0
  34. package/build/indexing/status.test.js.map +1 -1
  35. package/build/indexing/workflow.d.ts +1 -1
  36. package/build/indexing/workflow.js +27 -7
  37. package/build/indexing/workflow.js.map +1 -1
  38. package/build/indexing/workflow.test.js +53 -6
  39. package/build/indexing/workflow.test.js.map +1 -1
  40. package/build/server.js +10 -2
  41. package/build/server.js.map +1 -1
  42. package/build/server.test.js +19 -0
  43. package/build/server.test.js.map +1 -1
  44. package/build/types.d.ts +4 -0
  45. package/build/util/security.d.ts +13 -3
  46. package/build/util/security.js +38 -6
  47. package/build/util/security.js.map +1 -1
  48. package/build/util/security.test.js +28 -4
  49. package/build/util/security.test.js.map +1 -1
  50. package/package.json +2 -2
@@ -1,4 +1,4 @@
1
- import { SessionExpiredError } from '../util/security.js';
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(), warning = vi.fn()) {
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), warning);
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
- expect(warning).toHaveBeenCalledWith(expect.stringContaining('blocked outbound destination'));
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(), warning);
262
+ await runRequestHandler(page, 'https://example.com/docs', response());
264
263
  });
265
264
  await collect(crawler, 'https://example.com/docs');
266
- expect(warning).toHaveBeenCalledWith(expect.stringContaining('blocked outbound destination'));
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(), warning);
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(warning).not.toHaveBeenCalled();
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(), warning);
282
+ await runRequestHandler(page, 'https://example.com/docs', response());
286
283
  });
287
284
  await collect(crawler, 'https://example.com/docs');
288
- expect(warning).toHaveBeenCalledWith(expect.stringContaining('blocked outbound destination'));
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
- // The check only runs on a crawl that authenticated, so every case here starts from one
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
- // One login page, whatever URL it was asked for
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
- it('fails the crawl when the first page is a login page', async () => {
436
- await expect(crawlPages([{ url: 'https://example.com/docs', bodyText: LOGIN_BODY }])).rejects.toThrow(SessionExpiredError);
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
- // Same outcome whichever order they finish in, which "in a row" could never give
451
- it.each([
452
- ['one login page answers several URLs', [docsPage(1), loginPage(2), loginPage(3), loginPage(4)]],
453
- ['real pages are interleaved with it', [docsPage(1), loginPage(2), docsPage(3), loginPage(4), docsPage(5), loginPage(6)]],
454
- ])('fails the crawl when %s', async (_label, pages) => {
455
- await expect(crawlPages(pages)).rejects.toThrow(/one login page answered 3 different URLs/i);
456
- });
457
- // Too short for any one login page to come back three times, but they were still most of it
458
- it('fails a short crawl that was mostly one login page', async () => {
459
- await expect(crawlPages([docsPage(1), loginPage(2), loginPage(3)])).rejects.toThrow(/answered 2 of 3 URLs/i);
460
- });
461
- // Two indicators is the detector's bar but half of ours, or any page mentioning a username counts
462
- it('ignores pages that only just trip the detector', async () => {
463
- const pages = [1, 2, 3].map((n) => ({ ...loginPage(n), bodyText: 'Log in with your username.' }));
464
- await expect(crawlPages([docsPage(0), ...pages])).resolves.toBeUndefined();
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 repeat count catches it
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) => ({ ...loginPage(n), url: `https://example.com/docs/late-${n}` }));
469
- await expect(crawlPages([...[1, 2, 3, 4, 5, 6, 7, 8].map(docsPage), ...tail])).rejects.toThrow(/one login page answered 3 different URLs/i);
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(SessionExpiredError);
522
+ await expect(crawlPages([docsPage(0), ...pages])).rejects.toThrow(LoginPageServedError);
478
523
  });
479
- // One page is documentation about signing in; the rule needs a page that came back
480
- it('keeps crawling a two-page site whose second page is about signing in', async () => {
481
- await expect(crawlPages([docsPage(1), { ...loginPage(2), url: 'https://example.com/docs/auth' }])).resolves.toBeUndefined();
482
- });
483
- // Exactly half, which "most of the crawl" has to include - otherwise a session dying at the
484
- // midpoint of a short crawl passes
485
- it('fails a crawl that was exactly half one login page', async () => {
486
- await expect(crawlPages([docsPage(1), loginPage(2), docsPage(3), loginPage(4)])).rejects.toThrow(/answered 2 of 4 URLs/i);
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
- // Shape alone cannot tell pages apart, so it never counts on its own
512
- it.each([
513
- ['a theme that renders no headings', [1, 2, 3].map((n) => ({ ...docsPage(n), headings: [] }))],
514
- [
515
- 'a reference template with fixed headings',
516
- [1, 2, 3].map((n) => ({ ...docsPage(n), headings: ['Authentication', 'Request', 'Response'] })),
517
- ],
518
- ])('keeps crawling documentation pages that share %s', async (_label, pages) => {
519
- const aboutSigningIn = pages.map((page) => ({ ...page, bodyText: LOGIN_BODY }));
520
- await expect(crawlPages([docsPage(0), ...aboutSigningIn])).resolves.toBeUndefined();
521
- expect(mockQueueManager.addResult).toHaveBeenCalledTimes(4);
522
- });
523
- // A sign-in box in the page furniture. What still differs is what each page is about.
524
- it('keeps crawling a site whose every page carries a sign-in box', async () => {
525
- const pages = [1, 2, 3].map((n) => ({ ...loginPage(n), bodyText: `${LOGIN_BODY} Topic ${n}`, headings: [`Topic ${n}`] }));
526
- await expect(crawlPages([docsPage(0), ...pages])).resolves.toBeUndefined();
527
- expect(mockQueueManager.addResult).toHaveBeenCalledTimes(4);
528
- });
529
- // A sign-in box and no headings: neither half is enough to call these one page
530
- it('keeps crawling a heading-less site whose every page carries a sign-in box', async () => {
531
- const pages = [1, 2, 3].map((n) => ({
532
- ...loginPage(n),
533
- bodyText: `${LOGIN_BODY} ${'Ordinary content about topic '.repeat(8)}${n}`,
534
- headings: [],
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
- // The heading of a login page names where it is sending you back to
540
- it('fails the crawl when the login page heading names the URL it turned away', async () => {
541
- const pages = [1, 2, 3].map((n) => ({
542
- ...loginPage(n),
543
- headings: [`Sign in to continue to /docs/${n}`],
544
- }));
545
- await expect(crawlPages([docsPage(0), ...pages])).rejects.toThrow(/one login page answered 3 different URLs/i);
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
- // At two, a site with two pages about signing in fails a crawl that is otherwise fine
548
- it('keeps crawling when one login page has only answered twice', async () => {
549
- await expect(crawlPages([...[1, 2, 3, 4, 5, 6].map(docsPage), loginPage(7), loginPage(8)])).resolves.toBeUndefined();
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
- // Nothing in its text and no password field in the page, so the markup is the only evidence -
552
- // and reading the markup is what the crawl skips for a page showing no sign of a login
553
- it('fails the crawl when the entry page is a wordless login wall', async () => {
554
- const wall = {
555
- url: 'https://example.com/docs',
556
- bodyText: 'Redirecting',
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 beforeEach above authenticated, and this crawl must not have
562
- it('does not check for login pages when the crawl never authenticated', async () => {
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
- await expect(crawlPages([loginPage(1), loginPage(2), loginPage(3)])).resolves.toBeUndefined();
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>&lt;input type="password" name="password"&gt;</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', () => {