Skip to content

Commit 9a001c5

Browse files
feat: expand clickable elements (#131)
* feat: expand clickable elements * chore: simplify the clickable elements expansion and document it * chore: clean readme + adjust test
1 parent 6bd8e10 commit 9a001c5

7 files changed

Lines changed: 133 additions & 12 deletions

File tree

actors/apify_rag-web-browser/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The extracted text can then be injected into prompts and retrieval augmented gen
1616
- 🕷 Automatically **bypasses anti-scraping protections** using proxies and browser fingerprints
1717
- 📝 Output formats include **Markdown**, plain text, and HTML
1818
- 🔗 **Links are converted to absolute URLs**, so they stay valid outside of the page they came from
19+
- 🪗 **Collapsed sections are expanded** in Browser mode, so their content is not missing from the output
1920
- 🔌 Supports **OpenAPI and MCP** for easy integration
2021
- 🪟 It's **open source**, so you can review and modify it
2122

@@ -244,6 +245,16 @@ Media files carry no text for the LLM, so the Actor never downloads them:
244245
- Search results (and a `query` that is a URL) pointing directly to a media file, e.g. `https://example.com/video.mp4`,
245246
are not crawled at all. Such a result is returned with an empty text and `Skipped media file` as the HTTP status message.
246247

248+
### Collapsed content
249+
250+
Some web pages keep parts of their content hidden until the reader expands them, e.g. accordions or
251+
FAQ sections, and add it to the page only when it's clicked. To capture such content, the Actor clicks
252+
the collapsed elements of the page, i.e. those matching the `[aria-expanded="false"]` CSS selector,
253+
in Browser mode (`scrapingTool=browser-playwright`) before it extracts the content.
254+
255+
Elements linking to another page are not clicked, so that the Actor stays on the page it was asked
256+
to extract.
257+
247258
### Reducing response time
248259
249260
For low-latency applications, it's recommended to run the RAG Web Browser in Standby mode

actors/apify_url-to-markdown/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ Relative links such as `/docs` are resolved into absolute URLs against the page
102102
### What happens with media files?
103103
Media files carry no text to convert, so they are never downloaded. Images, audio, video, and fonts of the converted page are blocked in the Browser mode, and a URL pointing directly to a media file, e.g. `https://example.com/video.mp4`, is not fetched at all — the output contains no content and `Skipped media file` as the HTTP status message.
104104

105+
### What happens with collapsed content?
106+
Content that a page adds only when the reader expands it, e.g. accordions or FAQ sections, would be missing from the markdown. To capture it, the Browser mode clicks the collapsed elements of the page, i.e. those matching the `[aria-expanded="false"]` CSS selector, before converting it. Elements linking to another page are not clicked, so that the Actor stays on the page it was asked to convert.
107+
105108
### Can I use URL to Markdown with the Apify API?
106109
The Apify API gives you programmatic access to the Apify platform. The API is organized around RESTful HTTP endpoints that enable you to manage, schedule, and run Apify Actors. The API also lets you access any datasets, monitor Actor performance, fetch results, create and update versions, and more.
107110

src/request-handler.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ import {
1919
} from './website-content-crawler/html-processing.js';
2020
import { htmlToMarkdown } from './website-content-crawler/markdown.js';
2121

22+
/** Same as the default of the `clickElementsCssSelector` input of Website Content Crawler. */
23+
const CLICK_ELEMENTS_CSS_SELECTOR = '[aria-expanded="false"]';
24+
25+
const CLICK_RENDER_WAIT_MS = 500;
26+
2227
let ACTOR_TIMEOUT_AT: number | undefined;
2328
try {
2429
ACTOR_TIMEOUT_AT = process.env.ACTOR_TIMEOUT_AT ? new Date(process.env.ACTOR_TIMEOUT_AT).getTime() : undefined;
@@ -78,6 +83,34 @@ export async function waitForDynamicContent(context: PlaywrightCrawlingContext,
7883
}
7984
}
8085

86+
/**
87+
* Tries to expand collapsed content by clicking on it, so that its text is included in the extracted
88+
* content, e.g. https://www.checkout.com/docs/support/reporting (adapted from: Website Content Crawler).
89+
*
90+
* A click handler can still navigate the page with JavaScript, and then the content is extracted from
91+
* the page it navigated to, the same as in Website Content Crawler.
92+
*/
93+
async function expandClickableElements(page: PlaywrightCrawlingContext['page'], cssSelector: string) {
94+
const clickedCount = await page.evaluate((selector) => {
95+
// only click on items that don't have `href` attribute or they lead to the current page
96+
const elements = [...document.querySelectorAll(selector)].filter((el) => {
97+
const href = el.getAttribute('href');
98+
return (!href || href.startsWith('#')) && typeof (el as HTMLElement).click === 'function';
99+
});
100+
101+
for (const el of elements) {
102+
(el as HTMLElement).click();
103+
}
104+
105+
return elements.length;
106+
}, cssSelector);
107+
108+
if (clickedCount > 0) {
109+
log.debug(`Clicked ${clickedCount} element(s) matching \`${cssSelector}\``);
110+
await sleep(CLICK_RENDER_WAIT_MS);
111+
}
112+
}
113+
81114
type ContentCrawlingContext = PlaywrightCrawlingContext<ContentCrawlerUserData> | CheerioCrawlingContext<ContentCrawlerUserData>;
82115

83116
function isValidContentType(contentType: string | undefined) {
@@ -249,6 +282,11 @@ export async function requestHandlerPlaywright(
249282
addTimeMeasureEvent(request.userData, 'playwright-remove-cookie');
250283
}
251284

285+
if (page) {
286+
await expandClickableElements(page, CLICK_ELEMENTS_CSS_SELECTOR);
287+
addTimeMeasureEvent(request.userData, 'playwright-expand-clickable-elements');
288+
}
289+
252290
// Parsing the page after the dynamic content has been loaded / cookie warnings removed
253291
const $ = await context.parseWithCheerio();
254292
addTimeMeasureEvent(request.userData, 'playwright-parse-with-cheerio');

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export interface TimeMeasure {
7676
| 'error'
7777
| 'playwright-request-start'
7878
| 'playwright-wait-dynamic-content'
79+
| 'playwright-expand-clickable-elements'
7980
| 'playwright-parse-with-cheerio'
8081
| 'playwright-process-html'
8182
| 'playwright-remove-cookie'

tests/helpers/html/clickable.html

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Clickable Test Page</title>
7+
</head>
8+
<body>
9+
<p>always visible content</p>
10+
11+
<button id="toggle">Show details</button>
12+
<div id="panel"></div>
13+
14+
<a id="anchor" href="#anchor-panel">Show more</a>
15+
<div id="anchor-panel"></div>
16+
17+
<a id="link" href="/basic#section">Link to another page</a>
18+
<div id="link-panel"></div>
19+
20+
<script>
21+
// Collapsed content is added to the page only when clicked, and takes a moment to render
22+
const reveal = (panelId, text) => {
23+
window.setTimeout(() => {
24+
document.getElementById(panelId).textContent = text;
25+
}, 150);
26+
};
27+
28+
document.getElementById('toggle').addEventListener('click', () => reveal('panel', 'collapsed panel content'));
29+
document.getElementById('anchor').addEventListener('click', () => reveal('anchor-panel', 'anchor panel content'));
30+
document.getElementById('link').addEventListener('click', () => reveal('link-panel', 'link panel content'));
31+
32+
// The elements become collapsed only once the page is interactive, so they cannot be clicked
33+
// before the Actor waits for the dynamic content
34+
window.setTimeout(() => {
35+
for (const id of ['toggle', 'anchor', 'link']) {
36+
document.getElementById(id).setAttribute('aria-expanded', 'false');
37+
}
38+
}, 200);
39+
</script>
40+
</body>
41+
</html>

tests/helpers/server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ export function createTestServer() {
3030
sendHtml('basic.html', res);
3131
});
3232

33+
app.get('/clickable', (_req, res) => {
34+
sendHtml('clickable.html', res);
35+
});
36+
3337
app.get('/with-image', (_req, res) => {
3438
sendHtml('with-image.html', res);
3539
});

tests/playwright-crawler.content.test.ts

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { firefox } from 'playwright';
77
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
88

99
import { requestHandlerPlaywright } from '../src/request-handler.js';
10-
import type { ContentCrawlerUserData } from '../src/types.js';
10+
import type { ContentCrawlerUserData, ContentScraperSettings, Output } from '../src/types.js';
1111
import { createRequest } from '../src/utils.js';
1212
import { startTestServer, stopTestServer } from './helpers/server.js';
1313

@@ -27,9 +27,12 @@ describe('Playwright Crawler Content Tests', () => {
2727
await stopTestServer(testServer);
2828
});
2929

30-
it('test basic content extraction with playwright', async () => {
30+
/**
31+
* Scrapes a single URL and returns the results the request handler pushed to the dataset.
32+
*/
33+
async function scrapeWithPlaywright(url: string, settings: Partial<ContentScraperSettings> = {}) {
34+
const results: Output[] = [];
3135
const failedUrls = new Set<string>();
32-
const successUrls = new Set<string>();
3336

3437
// Create memory storage and request queue
3538
const client = new MemoryStorage({ persistStorage: false });
@@ -38,14 +41,10 @@ describe('Playwright Crawler Content Tests', () => {
3841
const crawler = new PlaywrightCrawler({
3942
requestQueue,
4043
requestHandler: async (context) => {
41-
const pushDataSpy = vi.spyOn(context, 'pushData').mockResolvedValue(undefined);
44+
vi.spyOn(context, 'pushData').mockImplementation(async (data) => {
45+
results.push(data as Output);
46+
});
4247
await requestHandlerPlaywright(context as unknown as PlaywrightCrawlingContext<ContentCrawlerUserData>);
43-
44-
expect(pushDataSpy).toHaveBeenCalledTimes(1);
45-
expect(pushDataSpy).toHaveBeenCalledWith(expect.objectContaining({
46-
text: expect.stringContaining('hello world'),
47-
}));
48-
successUrls.add(context.request.url);
4948
},
5049
failedRequestHandler: async ({ request }, error) => {
5150
log.error(`Request ${request.url} failed with error: ${error.message}`);
@@ -65,7 +64,7 @@ describe('Playwright Crawler Content Tests', () => {
6564
const r = createRequest(
6665
'query',
6766
{
68-
url: `${baseUrl}/basic`,
67+
url,
6968
description: 'Test request',
7069
rank: 1,
7170
title: 'Test title',
@@ -76,6 +75,7 @@ describe('Playwright Crawler Content Tests', () => {
7675
outputFormats: ['text'],
7776
maxHtmlCharsToProcess: 100000,
7877
dynamicContentWaitSecs: 20,
78+
...settings,
7979
},
8080
[],
8181
);
@@ -85,7 +85,30 @@ describe('Playwright Crawler Content Tests', () => {
8585

8686
await crawler.run();
8787

88+
return { results, failedUrls };
89+
}
90+
91+
it('test basic content extraction with playwright', async () => {
92+
const { results, failedUrls } = await scrapeWithPlaywright(`${baseUrl}/basic`);
93+
94+
expect(failedUrls.size).toBe(0);
95+
expect(results).toHaveLength(1);
96+
expect(results[0].text).toContain('hello world');
97+
});
98+
99+
it('expands clickable elements to extract collapsed content', async () => {
100+
const { results, failedUrls } = await scrapeWithPlaywright(`${baseUrl}/clickable`, {
101+
dynamicContentWaitSecs: 2,
102+
});
103+
88104
expect(failedUrls.size).toBe(0);
89-
expect(successUrls.size).toBe(1);
105+
expect(results).toHaveLength(1);
106+
expect(results[0].text).toContain('always visible content');
107+
// Content of every collapsed element, which is added to the page only once it's clicked
108+
expect(results[0].text).toContain('collapsed panel content');
109+
expect(results[0].text).toContain('anchor panel content');
110+
// The link leading to another page must not be clicked, not even to its fragment
111+
expect(results[0].text).not.toContain('link panel content');
112+
expect(results[0].text).not.toContain('hello world');
90113
});
91114
});

0 commit comments

Comments
 (0)