Skip to content

Commit 3e7325e

Browse files
committed
agents: add distill-page skill with tests
1 parent ee5da93 commit 3e7325e

27 files changed

Lines changed: 385816 additions & 3 deletions

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "agents/skills/distill-page/turndown_tmp"]
2+
path = agents/skills/distill-page/turndown_tmp
3+
url = https://github.com/mixmark-io/turndown.git

agents/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# @paulirish/agents
22

3-
A collection of expert agent skills and command-line tools for Gemini and Claude.
3+
A collection of expert agent skills and CLIs.
44

55
## Installing Skills
66

7-
Skills can be installed directly into your agent environment (e.g., Gemini CLI, Claude) using the `skills` CLI:
7+
Skills can be installed directly into your coding agent using the vercel `skills` CLI:
88

99
```bash
1010
npx skills add paulirish/dotfiles/agents --skill <skill-name>

agents/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"dependencies": {
1919
"heap-snapshot-toolkit": "^1.1.3",
2020
"playwright": "^1.61.1",
21-
"markpaste": "^0.0.6"
21+
"markpaste": "^0.0.6",
22+
"protobufjs": "^8.7.1"
2223
},
2324
"devDependencies": {
2425
"typescript": "^5.0.0"

agents/pnpm-lock.yaml

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
name: distill-page
3+
description: Extract distilled page content as Markdown using Chromium's on-device ML page content annotation.
4+
---
5+
6+
# Page Distiller Skill
7+
8+
This skill extracts high-quality, noise-free Markdown content from a webpage using Chromium's experimental, on-device Machine Learning model annotations (`Page.getAnnotatedPageContent`).
9+
10+
Unlike generic HTML-to-Markdown parsers (e.g. Turndown) which rely on markup syntax heuristics, this approach uses Chromium's visual ML layout analysis. It automatically identifies semantic roles (such as `ARTICLE` and `MAIN`) and excludes sidebars, navigation elements, footers, and popups.
11+
12+
## Usage
13+
14+
```bash
15+
node agents/skills/distill-page/scripts/distill-page.ts [--markdown] <URL>
16+
```
17+
18+
### Options:
19+
* `--markdown`: Output as distilled Markdown (recommended). Without this, it outputs the raw decoded protobuf JSON.
20+
* `--base64-stdin`: Instead of fetching via Playwright, decode a base64-encoded protobuf payload passed via stdin.
21+
22+
### Examples:
23+
```bash
24+
# Get markdown representation of page
25+
node agents/skills/distill-page/scripts/distill-page.ts --markdown https://example.com
26+
27+
# Get raw JSON structure of annotated layout tree
28+
node agents/skills/distill-page/scripts/distill-page.ts https://example.com
29+
```
30+
31+
## Requirements
32+
* Launches a headful browser (`headless: false`) and requires Chromium feature flags:
33+
* `--enable-dom-distiller`
34+
* `--enable-features=OptimizationHints,PageContentAnnotation,OptimizationGuideModelDownloading`
35+
* Playwright must be installed.
36+
* ProtobufJS must be installed.
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import * as pb from './proto/common_quality_data_pbjs.js';
2+
3+
function normalizeWhitespace(text: string): string {
4+
return text.replace(/\s+/g, ' ').trim();
5+
}
6+
7+
const {ContentAttributeType, TextSize, TableRowType, AnnotatedRole} = pb.optimization_guide.proto;
8+
const {
9+
CONTENT_ATTRIBUTE_TEXT,
10+
CONTENT_ATTRIBUTE_IMAGE,
11+
CONTENT_ATTRIBUTE_ANCHOR,
12+
CONTENT_ATTRIBUTE_ORDERED_LIST,
13+
CONTENT_ATTRIBUTE_UNORDERED_LIST,
14+
CONTENT_ATTRIBUTE_TABLE,
15+
CONTENT_ATTRIBUTE_TABLE_ROW,
16+
CONTENT_ATTRIBUTE_HEADING,
17+
CONTENT_ATTRIBUTE_CONTAINER
18+
} = ContentAttributeType;
19+
type AnyNode = pb.optimization_guide.proto.IContentNode;
20+
type IAnnotatedPageContent = pb.optimization_guide.proto.IAnnotatedPageContent;
21+
22+
export function decodeAnnotatedPageContent(base64String: string): IAnnotatedPageContent {
23+
const buffer = Buffer.from(base64String, 'base64');
24+
const message = pb.optimization_guide.proto.AnnotatedPageContent.decode(buffer);
25+
return pb.optimization_guide.proto.AnnotatedPageContent.toObject(message, {
26+
longs: String,
27+
28+
arrays: true,
29+
objects: true,
30+
});
31+
}
32+
33+
function findNodeByRoles(node: AnyNode, roles: string[]): AnyNode | null {
34+
const attrs = node.contentAttributes || {};
35+
if (attrs.annotatedRoles) {
36+
for (const r of roles) {
37+
if (attrs.annotatedRoles.includes(r as any)) {
38+
return node;
39+
}
40+
}
41+
}
42+
43+
if (node.childrenNodes) {
44+
for (const child of node.childrenNodes) {
45+
const found = findNodeByRoles(child, roles);
46+
if (found) return found;
47+
}
48+
}
49+
return null;
50+
}
51+
52+
function recursiveToMarkdown(node: AnyNode, state: {imageIndex: number, insideHeading?: boolean, currentUrl?: string | null}): string {
53+
let md = '';
54+
const attrs = node.contentAttributes || {};
55+
56+
if (attrs.attributeType === CONTENT_ATTRIBUTE_TEXT && attrs.textData) {
57+
let text = attrs.textData.textContent || '';
58+
text = text.trim();
59+
if (text) {
60+
if (attrs.textData.textStyle?.hasEmphasis && text !== '`') {
61+
text = `**${text}**`;
62+
}
63+
if (attrs.textData.textStyle?.textSize) {
64+
const size = attrs.textData.textStyle.textSize;
65+
if (size === TextSize.TEXT_SIZE_XL || size === TextSize.TEXT_SIZE_L) {
66+
if (!state.insideHeading) {
67+
const prefix = size === TextSize.TEXT_SIZE_XL ? '##' : '###';
68+
text = `\n\n${prefix} ${text}\n\n`;
69+
}
70+
}
71+
}
72+
md += text;
73+
if (!text.endsWith('\n')) {
74+
md += ' ';
75+
}
76+
}
77+
} else if (attrs.attributeType === CONTENT_ATTRIBUTE_IMAGE && attrs.imageData) {
78+
const caption = attrs.imageData.imageCaption || 'image';
79+
if (attrs.imageData.url) {
80+
md += `\n![${caption}](${attrs.imageData.url})\n`;
81+
} else {
82+
const ref = `image${String(state.imageIndex++).padStart(2, '0')}`;
83+
md += `\n![${caption}][${ref}]\n`;
84+
}
85+
} else if (attrs.attributeType === CONTENT_ATTRIBUTE_ANCHOR && attrs.anchorData) {
86+
const url = attrs.anchorData.url || '';
87+
let resolvedUrl = url;
88+
if (state.currentUrl && url.startsWith(state.currentUrl + '#')) {
89+
resolvedUrl = url.substring(state.currentUrl.length);
90+
}
91+
let childText = '';
92+
if (node.childrenNodes) {
93+
for (const child of node.childrenNodes) {
94+
childText += recursiveToMarkdown(child, state);
95+
}
96+
}
97+
childText = childText.trim();
98+
if (childText && resolvedUrl) {
99+
md += `[${childText}](${resolvedUrl}) `; // we've processed children manually
100+
}
101+
return md;
102+
} else if (
103+
attrs.attributeType === CONTENT_ATTRIBUTE_ORDERED_LIST ||
104+
attrs.attributeType === CONTENT_ATTRIBUTE_UNORDERED_LIST
105+
) {
106+
let listMd = '\n\n';
107+
const isOrdered = attrs.attributeType === CONTENT_ATTRIBUTE_ORDERED_LIST;
108+
let index = 1;
109+
110+
if (node.childrenNodes) {
111+
for (const item of node.childrenNodes) {
112+
// Even if it isn't strictly CONTENT_ATTRIBUTE_LIST_ITEM, process it as a list child
113+
let itemText = '';
114+
if (item.childrenNodes) {
115+
for (const content of item.childrenNodes) {
116+
itemText += recursiveToMarkdown(content, state);
117+
}
118+
}
119+
itemText = normalizeWhitespace(itemText);
120+
if (itemText) {
121+
const prefix = isOrdered ? `${index}. ` : '* ';
122+
listMd += `${prefix}${itemText}\n`;
123+
index++;
124+
}
125+
}
126+
}
127+
return listMd + '\n';
128+
} else if (attrs.attributeType === CONTENT_ATTRIBUTE_TABLE) {
129+
let tableMd = '\n\n';
130+
let isFirstRow = true;
131+
if (node.childrenNodes) {
132+
for (const row of node.childrenNodes) {
133+
const rowAttrs = row.contentAttributes || {};
134+
if (rowAttrs.attributeType !== CONTENT_ATTRIBUTE_TABLE_ROW) continue;
135+
136+
let rowText = '|';
137+
let sepText = '|';
138+
if (row.childrenNodes) {
139+
for (const cell of row.childrenNodes) {
140+
let cellText = '';
141+
if (cell.childrenNodes) {
142+
for (const content of cell.childrenNodes) {
143+
cellText += recursiveToMarkdown(content, state);
144+
}
145+
}
146+
cellText = normalizeWhitespace(cellText);
147+
rowText += ` ${cellText} |`;
148+
sepText += `---|`;
149+
}
150+
}
151+
tableMd += rowText + '\n';
152+
if (isFirstRow || (rowAttrs.tableRowData && rowAttrs.tableRowData.type === TableRowType.TABLE_ROW_TYPE_HEADER)) {
153+
if (isFirstRow) {
154+
tableMd += sepText + '\n';
155+
}
156+
}
157+
isFirstRow = false;
158+
}
159+
}
160+
return tableMd + '\n';
161+
} else if (attrs.attributeType === CONTENT_ATTRIBUTE_HEADING) {
162+
let maxTextSize = TextSize.TEXT_SIZE_M_DEFAULT;
163+
if (node.childrenNodes) {
164+
for (const child of node.childrenNodes) {
165+
if (child.contentAttributes?.textData?.textStyle?.textSize) {
166+
const size = child.contentAttributes.textData.textStyle.textSize;
167+
if (size > maxTextSize) {
168+
maxTextSize = size;
169+
}
170+
}
171+
}
172+
}
173+
174+
let headingText = '';
175+
if (node.childrenNodes) {
176+
const oldInsideHeading = state.insideHeading;
177+
state.insideHeading = true;
178+
for (const child of node.childrenNodes) {
179+
headingText += recursiveToMarkdown(child, state);
180+
}
181+
state.insideHeading = oldInsideHeading;
182+
}
183+
headingText = normalizeWhitespace(headingText);
184+
if (headingText) {
185+
let prefix = '## ';
186+
if (maxTextSize === TextSize.TEXT_SIZE_L) {
187+
prefix = '### ';
188+
} else if (maxTextSize === TextSize.TEXT_SIZE_XL) {
189+
prefix = '## ';
190+
}
191+
return `\n\n${prefix}${headingText}\n\n`;
192+
}
193+
return '';
194+
195+
}
196+
197+
if (
198+
attrs.annotatedRoles &&
199+
((attrs.annotatedRoles || []).includes(AnnotatedRole.ANNOTATED_ROLE_NAV) || (attrs.annotatedRoles || []).includes(AnnotatedRole.ANNOTATED_ROLE_FOOTER))
200+
) {
201+
return ''; // Skip rendering kids of nav/footer
202+
}
203+
204+
if (node.childrenNodes) {
205+
for (const child of node.childrenNodes) {
206+
md += recursiveToMarkdown(child, state);
207+
}
208+
}
209+
210+
if (attrs.attributeType === CONTENT_ATTRIBUTE_CONTAINER) {
211+
md += '\n\n';
212+
}
213+
214+
return md;
215+
}
216+
217+
export function convertToMarkdown(decodedProto: IAnnotatedPageContent): string {
218+
const root = decodedProto.rootNode;
219+
if (!root) return '';
220+
221+
// Try to find the narrowest main content area to avoid outer shells
222+
const contentRoot =
223+
findNodeByRoles(root, [AnnotatedRole.ANNOTATED_ROLE_ARTICLE as unknown as string, AnnotatedRole.ANNOTATED_ROLE_MAIN as unknown as string]) || root;
224+
225+
let rawMd = recursiveToMarkdown(contentRoot, {imageIndex: 1, currentUrl: decodedProto.mainFrameData?.url});
226+
227+
// Clean up formatting
228+
rawMd = rawMd
229+
.replace(/\n{3,}/g, '\n\n')
230+
.replace(/ \n/g, '\n')
231+
.replace(/ +([.,!?;:)])/g, '$1')
232+
.replace(/`\s+/g, '`')
233+
.replace(/\s+`/g, '`')
234+
.replace(/^\s+|\s+$/g, '');
235+
236+
return rawMd;
237+
}

0 commit comments

Comments
 (0)