Skip to content

Commit b8ab329

Browse files
OsamaAnsarclaudenikitachapovskii-devnicklamonov
authored
feat: add links output format (#149)
* feat: add `links` output format Adds `links` as a fourth value for `outputFormats`. When requested, each result carries `links`: the de-duplicated list of absolute HTTP(S) URLs found in the page's `<a href>` elements, resolved against the loaded URL. Non-HTTP(S) schemes, unparseable hrefs and bare same-page anchors are dropped; first-appearance order is preserved. - `extractLinks($, baseUrl)` in html-processing, wired into `handleContent` for both the Cheerio and Playwright crawlers - `OutputFormats` union, `Output` type, input validation, the Actor input schema, the standby OpenAPI doc and the README updated - unit tests for resolution, de-dup/order, scheme filtering and empty input Closes #33 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * review: exercise malformed-href path, document `links` in OpenAPI - add a `http://[::1` fixture to the extractLinks test so the try/catch (unparseable absolute URL) is actually covered - describe the `links` result shape in the standby OpenAPI param and align the input-schema wording ("ordered, de-duplicated array") Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: resolve `links` against the document base URL and keep hash-route links - Pass the already computed `baseUrl` (which honours `<base href>`) to `extractLinks` instead of the page URL. - Keep hash-routing links such as `#/quickstart` and `#!/page`, and still drop plain in-page anchors (`#section`, `#`). - Skip unrendered template hrefs (`{{ ... }}`) and collect image map `<area href>` links. - Document that raw-HTTP mode only sees the links present in the server's HTML. - Add tests for each of these, including an end-to-end `<base href>` case. * feat: return the link text with each URL in the `links` output `links` is now an ordered, de-duplicated array of `{ url, text? }` objects instead of bare URLs, so that a consumer can tell "Pricing" from "Privacy policy" without guessing from the path. - `text` is the text of the link, falling back to its `aria-label`, `title` and `alt` attributes and then to the `alt` of an image inside it. Content of script, style, noscript and template elements is left out. - Whitespace is collapsed and the text is cut to 200 characters (by code points, so an emoji is never split). `text` is omitted when the link has none. - A URL that appears more than once keeps its first non-empty text. - Update the `Output` type, the input schema, the standby OpenAPI document, the Actor README and the tests. This is a separate commit so it can be dropped to go back to a plain `string[]`. * Update docs/standby-openapi-3.1.0.json Co-authored-by: Nick Lamonov <nikita.lamonov@apify.com> * Update actors/apify_rag-web-browser/README.md Co-authored-by: Nick Lamonov <nikita.lamonov@apify.com> * Update actors/apify_rag-web-browser/.actor/input_schema.json Co-authored-by: Nick Lamonov <nikita.lamonov@apify.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Nikita Chapovskii <nikita.chapovskii@apify.com> Co-authored-by: Nick Lamonov <nikita.lamonov@apify.com>
1 parent 174e78b commit b8ab329

9 files changed

Lines changed: 343 additions & 9 deletions

File tree

‎actors/apify_rag-web-browser/.actor/input_schema.json‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,14 @@
3333
"enum": [
3434
"text",
3535
"markdown",
36-
"html"
36+
"html",
37+
"links"
3738
],
3839
"enumTitles": [
3940
"Plain text",
4041
"Markdown",
41-
"HTML"
42+
"HTML",
43+
"Links"
4244
]
4345
}
4446
},

‎actors/apify_rag-web-browser/README.md‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ similar to the [web browsing](https://openai.com/index/introducing-chatgpt-searc
1313
- ⚙️ Supports **dynamic JavaScript-heavy websites** using a headless browser
1414
- 🔄 **Flexible scraping** with Browser mode for complex websites or Plain HTML mode for faster scraping
1515
- 🕷 Automatically **bypasses anti-scraping protections** using proxies and browser fingerprints
16-
- 📝 Output formats include **Markdown**, plain text, and HTML
16+
- 📝 Output formats include **Markdown**, plain text, HTML, and a list of links
1717
- 📄 **Markdown and plain text files**, such as `agents.md` or `llms.txt`, are returned unchanged
1818
- 🔗 **Links on web pages are converted to absolute URLs**, so they stay valid outside of the page they came from
1919
- 🪗 **Collapsed sections are expanded** in Browser mode, so their content is not missing from the output
@@ -234,6 +234,29 @@ Files that are Markdown or plain text already, e.g. `agents.md` or `llms.txt`, a
234234
(`text/markdown` or `text/plain`) and returned unchanged in `markdown` and `text`, with either scraping tool. Their
235235
relative links are therefore not converted to absolute URLs.
236236

237+
### Links
238+
239+
The `links` output format returns an ordered, de-duplicated array of the links on the page (`<a href>` and image map
240+
`<area href>`). Each link is an object with its absolute HTTP(S) `url`, resolved against the page's `<base href>`
241+
when it has one, and, when available, the visible link `text`:
242+
243+
```json
244+
"links": [
245+
{ "url": "https://example.com/pricing", "text": "Pricing" },
246+
{ "url": "https://example.com/assets/logo.svg" }
247+
]
248+
```
249+
250+
The `text` is the text of the link, or for an icon or image link its `aria-label`, `title` or `alt`, or the `alt` of
251+
an image inside it. Whitespace is collapsed, the text is cut to 200 characters, and `text` is left out when the link has
252+
none. If a URL appears more than once, its first non-empty text is kept. Links to other schemes (e.g. `mailto:`) and
253+
in-page anchors (`#section`) are left out, while the hash-routing links of single-page apps (e.g. `#/quickstart` or
254+
`#!/page`) are kept.
255+
256+
The tool that fetches the page decides which links it sees. In Plain HTML mode (`scrapingTool=raw-http`), only the links
257+
that are present in the HTML returned by the server are found, so JavaScript-heavy pages return far fewer links than in
258+
Browser mode (`scrapingTool=browser-playwright`), which reads the page after its JavaScript has run.
259+
237260
### Collapsed content
238261

239262
Some web pages keep parts of their content hidden until the reader expands them, e.g. accordions or

‎docs/standby-openapi-3.1.0.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@
5252
"enum": [
5353
"text",
5454
"markdown",
55-
"html"
55+
"html",
56+
"links"
5657
]
5758
},
5859
"default": [

‎src/input.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,8 @@ async function processRagWebBrowserInput(input: Partial<RagWebBrowserInput>, sta
119119
if (!input.outputFormats || input.outputFormats.length === 0) {
120120
input.outputFormats = ragWebBrowserInputSchema.properties.outputFormats.default as OutputFormats[];
121121
log.info(`The \`outputFormats\` parameter is not defined. Using default value \`${input.outputFormats}\`.`);
122-
} else if (input.outputFormats.some((format) => !['text', 'markdown', 'html'].includes(format))) {
123-
throw new UserInputError('The `outputFormats` array may only contain `text`, `markdown`, or `html`.');
122+
} else if (input.outputFormats.some((format) => !['text', 'markdown', 'html', 'links'].includes(format))) {
123+
throw new UserInputError('The `outputFormats` array may only contain `text`, `markdown`, `html`, or `links`.');
124124
}
125125

126126
// SERP proxy group

‎src/request-handler.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { addTimeMeasureEvent, isActorStandby, transformTimeMeasuresToRelative }
1313
import {
1414
extractCanonicalUrl,
1515
extractJsonLd,
16+
extractLinks,
1617
extractOpenGraphProperties,
1718
extractTitle,
1819
getDocumentBaseUrl,
@@ -228,6 +229,9 @@ async function handleContent(
228229
? htmlToMarkdown(processedHtml, pageUrl, baseUrl)
229230
: undefined,
230231
html: settings.outputFormats.includes('html') ? processedHtml : undefined,
232+
links: settings.outputFormats.includes('links')
233+
? extractLinks($, baseUrl)
234+
: undefined,
231235
};
232236

233237
addTimeMeasureEvent(request.userData, `${crawlerType}-before-response-send`);

‎src/types.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type { ContentCrawlerTypes } from './const.js';
1212
*/
1313
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
1414

15-
export type OutputFormats = 'text' | 'markdown' | 'html';
15+
export type OutputFormats = 'text' | 'markdown' | 'html' | 'links';
1616
export type SERPProxyGroup = 'GOOGLE_SERP' | 'SHADER';
1717
export type ScrapingTool = 'browser-playwright' | 'raw-http';
1818

@@ -141,10 +141,17 @@ export type ContentCrawlerUserData = {
141141
actorRequestId?: string;
142142
};
143143

144+
/** A link found on a page: its absolute HTTP(S) URL and, when it has any, its visible text. */
145+
export type Link = {
146+
url: string;
147+
text?: string;
148+
};
149+
144150
export type Output = {
145151
text?: string | null;
146152
html?: string | null;
147153
markdown?: string | null;
154+
links?: Link[];
148155
query?: string;
149156
crawl: {
150157
createdAt?: Date;

‎src/website-content-crawler/html-processing.ts‎

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { CheerioAPI } from 'crawlee';
22
import { log } from 'crawlee';
3+
import type { Element } from 'domhandler';
34

4-
import type { ContentScraperSettings, OpenGraphProperty } from '../types.js';
5+
import type { ContentScraperSettings, Link, OpenGraphProperty } from '../types.js';
56
import { readableText } from './text-extractor.js';
67

78
const SKIP_CHILD_OF_ELEMENT_SELECTORS = ['.crawlee-iframe-replacement *', 'svg *'].join(', ');
@@ -55,6 +56,86 @@ export function extractCanonicalUrl($: CheerioAPI, baseUrl: string): string | un
5556
return undefined;
5657
}
5758

59+
/**
60+
* A bare same-page anchor (`#section`, `#`) points into the page we already have. Hash-routing links of
61+
* single-page apps (`#/quickstart`, `#!/page`) are navigation, so they are not anchors.
62+
*/
63+
const isSamePageAnchor = (href: string) => href.startsWith('#') && !/^#[!/]/.test(href);
64+
65+
/** The text of a link is cut to this many characters, so that a link wrapping a whole card stays short. */
66+
const MAX_LINK_TEXT_LENGTH = 200;
67+
68+
/** Elements whose content is code or markup that is not shown, even when they sit inside a link. */
69+
const NON_VISIBLE_TEXT_SELECTOR = 'script, style, noscript, template';
70+
71+
/** Collapses whitespace and cuts to `MAX_LINK_TEXT_LENGTH` characters (code points, so that no emoji is split). */
72+
function normalizeLinkText(text: string | undefined): string {
73+
const collapsed = (text ?? '').replace(/\s+/g, ' ').trim();
74+
return Array.from(collapsed).slice(0, MAX_LINK_TEXT_LENGTH).join('').trim();
75+
}
76+
77+
/**
78+
* The visible text of a link, falling back to its accessible names for icon and image links: the
79+
* `aria-label`, `title` and `alt` attributes, and the `alt` of an image inside it. The result is plain text.
80+
*/
81+
function extractLinkText($: CheerioAPI, element: Element): string {
82+
const $el = $(element);
83+
const getVisibleText = () => {
84+
const $copy = $el.clone();
85+
$copy.find(NON_VISIBLE_TEXT_SELECTOR).remove();
86+
return $copy.text();
87+
};
88+
const candidates = [
89+
getVisibleText,
90+
() => $el.attr('aria-label'),
91+
() => $el.attr('title'),
92+
() => $el.attr('alt'),
93+
() => $el.find('img[alt]').first().attr('alt'),
94+
];
95+
96+
for (const candidate of candidates) {
97+
const text = normalizeLinkText(candidate());
98+
if (text) return text;
99+
}
100+
return '';
101+
}
102+
103+
/**
104+
* Collects every `<a href>` and image map `<area href>` on the page as a de-duplicated list of links, with
105+
* absolute HTTP(S) URLs resolved against `baseUrl` and the visible text of the link when it has any.
106+
* Non-HTTP(S) schemes (`mailto:`, `tel:`, `javascript:`, …), unparseable hrefs, bare same-page anchors
107+
* (`#section`) and unrendered template hrefs (`{{ item.url }}`) are dropped. Hash-routing links (`#/page`,
108+
* `#!/page`) are kept. Order of first appearance is preserved, and a URL that appears more than once keeps
109+
* the first non-empty text.
110+
*/
111+
export function extractLinks($: CheerioAPI, baseUrl: string): Link[] {
112+
const links = new Map<string, Link>();
113+
114+
for (const element of $('a[href], area[href]').get()) {
115+
const href = $(element).attr('href')?.trim();
116+
if (!href || isSamePageAnchor(href) || href.includes('{{')) continue;
117+
118+
let url: URL;
119+
try {
120+
url = new URL(href, baseUrl);
121+
} catch {
122+
// Ignore hrefs that don't resolve to a valid URL.
123+
continue;
124+
}
125+
if (url.protocol !== 'http:' && url.protocol !== 'https:') continue;
126+
127+
const text = extractLinkText($, element);
128+
const existing = links.get(url.href);
129+
if (!existing) {
130+
links.set(url.href, text ? { url: url.href, text } : { url: url.href });
131+
} else if (!existing.text && text) {
132+
existing.text = text;
133+
}
134+
}
135+
136+
return [...links.values()];
137+
}
138+
58139
export function extractOpenGraphProperties($: CheerioAPI): OpenGraphProperty[] | undefined {
59140
const properties = $(OPEN_GRAPH_SELECTOR).get().flatMap((element) => {
60141
const property = $(element).attr('property');

0 commit comments

Comments
 (0)