@danypops/pi-web-spider 0.10.4

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/biome.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
3
+ "files": {
4
+ "includes": ["src/**/*.ts"]
5
+ },
6
+ "linter": {
7
+ "enabled": true,
8
+ "rules": {
9
+ "recommended": true,
10
+ "suspicious": {
11
+ "noConsole": "error"
12
+ }
13
+ }
14
+ }
15
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@danypops/pi-web-spider",
3
+ "version": "0.10.4",
4
+ "description": "Pi extension: web_fetch, web_crawl, web_search \u2014 structured scraping for AI agents",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "type": "module",
9
+ "main": "./src/index.ts",
10
+ "pi": {
11
+ "extensions": [
12
+ "./src/index.ts"
13
+ ]
14
+ },
15
+ "dependencies": {
16
+ "@danypops/web-spider": "*"
17
+ },
18
+ "peerDependencies": {
19
+ "@earendil-works/pi-coding-agent": "*"
20
+ },
21
+ "scripts": {
22
+ "test": "vitest --run",
23
+ "lint": "biome lint src/",
24
+ "check": "npm run lint && tsc --noEmit"
25
+ },
26
+ "devDependencies": {
27
+ "@biomejs/biome": "^2.4.15",
28
+ "@types/node": "^22.19.19",
29
+ "jiti": "^2.0.0",
30
+ "typescript": "^5.9.3",
31
+ "vitest": "^3.0.0"
32
+ }
33
+ }
package/src/format.ts ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Agent-optimised output formatters for the web_fetch tool.
3
+ *
4
+ * Every field in every output must be actionable — the agent must be able to
5
+ * make a different decision based on its value. Fields that fail this test are
6
+ * omitted: domain (derivable from url), readingTimeMinutes (wordCount / 200,
7
+ * never acted on), headings inside markdown (already in the body), empty
8
+ * strings / arrays, and the snippet field in highlights (always a substring
9
+ * of the full chunk text that follows it).
10
+ *
11
+ * Link handling: SpideredPage carries rel:"body"|"nav" on every link.
12
+ * Body links are content references the author chose to include — high signal.
13
+ * Nav links are site chrome (menus, footers, breadcrumbs) — low signal.
14
+ * We surface them separately so the agent can focus on body links by default
15
+ * and ignore the nav flood that swamps many pages.
16
+ */
17
+
18
+ import type { SpideredPage } from "@danypops/web-spider";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Primitives
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Remove keys whose value is an empty string, empty array, false, or
26
+ * undefined. Keeps 0 and null intentionally — those are real values.
27
+ */
28
+ export function omitEmpty(obj: Record<string, unknown>): Record<string, unknown> {
29
+ return Object.fromEntries(
30
+ Object.entries(obj).filter(
31
+ ([, v]) => v !== undefined && v !== "" && v !== false && !(Array.isArray(v) && v.length === 0),
32
+ ),
33
+ );
34
+ }
35
+
36
+ /** Body links only — content references, not navigation chrome. */
37
+ export function bodyLinks(page: SpideredPage): Array<{ href: string; text: string }> {
38
+ return page.links
39
+ .filter((l) => l.rel === "body")
40
+ .map((l) => ({ href: l.href, text: l.text }));
41
+ }
42
+
43
+ /** Count of navigation links (menus, footers, breadcrumbs). */
44
+ export function navLinksCount(page: SpideredPage): number {
45
+ return page.links.filter((l) => l.rel === "nav").length;
46
+ }
47
+
48
+ /** Headings as flat markdown strings: "## Section Name". */
49
+ export function headingStrings(page: SpideredPage): string[] {
50
+ return page.headings.map((h) => `${"#".repeat(h.level)} ${h.text}`);
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Per-format output builders
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Lean output — identity, metadata signals, outline, and body links.
59
+ * No prose body. Use for triage: is this page relevant, and where should I
60
+ * look next?
61
+ *
62
+ * Omitted vs. raw SpideredPage:
63
+ * - domain (derivable from url)
64
+ * - readingTimeMinutes (wordCount / 200, never actionable)
65
+ * - fetchedAt (internal timing, not content)
66
+ * - lang (almost always "en", rarely acted on)
67
+ * - chunks / markdown (the whole point of lean is to skip them)
68
+ * - nav links (surfaced as navLinksCount instead of flooding bodyLinks)
69
+ * - all empty strings and empty arrays
70
+ */
71
+ export function leanOutput(page: SpideredPage): Record<string, unknown> {
72
+ return omitEmpty({
73
+ url: page.url,
74
+ title: page.title,
75
+ description: page.description,
76
+ author: page.author,
77
+ publishedAt: page.publishedAt,
78
+ tags: page.tags,
79
+ wordCount: page.wordCount,
80
+ headings: headingStrings(page),
81
+ bodyLinks: bodyLinks(page),
82
+ navLinksCount: navLinksCount(page) || undefined,
83
+ jsRendered: page.jsRendered || undefined,
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Markdown output — prose body plus the metadata fields an agent might act on.
89
+ *
90
+ * Omitted vs. raw SpideredPage:
91
+ * - domain (derivable from url)
92
+ * - readingTimeMinutes (wordCount / 200, never actionable)
93
+ * - headings (already present as ## lines inside markdown — would be duplicate)
94
+ * - links (not needed when reading prose; use format=links if you need them)
95
+ * - chunks (internal RAG structure; markdown is the readable form)
96
+ * - fetchedAt, lang
97
+ * - all empty strings and empty arrays
98
+ */
99
+ export function markdownOutput(page: SpideredPage): Record<string, unknown> {
100
+ return omitEmpty({
101
+ url: page.url,
102
+ title: page.title,
103
+ description: page.description,
104
+ author: page.author,
105
+ publishedAt: page.publishedAt,
106
+ wordCount: page.wordCount,
107
+ markdown: page.markdown,
108
+ jsRendered: page.jsRendered || undefined,
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Links output — body links only, nav link count for awareness.
114
+ * Use for graph traversal: which pages should I visit next?
115
+ */
116
+ export function linksOutput(page: SpideredPage): Record<string, unknown> {
117
+ return omitEmpty({
118
+ url: page.url,
119
+ title: page.title,
120
+ bodyLinks: bodyLinks(page),
121
+ navLinksCount: navLinksCount(page) || undefined,
122
+ });
123
+ }
124
+
125
+ /**
126
+ * A single highlights hit — the full chunk text only.
127
+ * snippet is omitted: it is always a substring of text, so including both
128
+ * repeats content and wastes tokens.
129
+ */
130
+ export function highlightHit(
131
+ h: { heading: string; score: number; snippet: string; chunkId?: string },
132
+ chunks: SpideredPage["chunks"],
133
+ ): Record<string, unknown> {
134
+ const text = h.chunkId
135
+ ? (chunks.find((c) => c.id === h.chunkId)?.text ?? h.snippet)
136
+ : h.snippet;
137
+ return omitEmpty({
138
+ heading: h.heading,
139
+ score: h.score,
140
+ text,
141
+ });
142
+ }