Manifest
Every definition and field of the schema 3 Feed Package manifest (fruitful-package.json).
The Feed Package manifest (fruitful-package.json, schema 3). Four blocks in data-flow order: capture, transform, render, focus. Every schema the manifest refers to is a Lexicon; the manifest itself is version pins and wiring. Executable code is declared in exactly two places: capture.hooks and transform.hooks. Cross-field rules (a route's login exists, hook exports match the module, a binding's emits matches transform.input, a from route names a pinned record) are declared as data in FEED_PACKAGE_MANIFEST_RELATIONSHIPS and enforced by the registry, not by this document.
Add a $schema line to fruitful-package.json for validation and autocomplete. Fruitful ignores the key.
{ "$schema": "https://fruitful.app/schemas/fruitful-package.schema.json", "schema": 3, "kind": "page-plugin" }Read top to bottom, the four blocks are the lifecycle of one followed page:
- capture: a feed is offered (Explore, schedule), its route captures the page (login, load, ready, hooks, scroll), and the binding, through its extractor engine, produces the extract.
- transform: the transform module turns the extract into Lexicon-validated records.
- render: the root record becomes an Activity item through the package View and A2UI surfaces.
- focus: independently of the pipeline, the browser extension hides or replaces distractions on the live site.
Executable code is declared in exactly two places: capture.hooks.file and transform.hooks.file. Rules that span fields are on the Relationships page; the engines a binding can name are on Extractor engines.
Examples come from the 33 committed first-party packages under feed-packages/.
main
A Feed Package manifest. Read top to bottom it is the lifecycle: a route is captured, the extract is transformed into records, records are rendered, and Focus is what the extension does on the live site meanwhile.
| Field | Type | Required | Description |
|---|---|---|---|
schema | 3 | yes | Manifest schema version. Schema 3 is the four-block layout; there is no dual-shape parsing. |
kind | "page-plugin" | yes | Package kind discriminator shared with lexicon and extractor package manifests. |
name | string | yes | Reverse-DNS package name, for example com.linkedin.posts. Immutable across versions. |
version | string | yes | Semantic version of this immutable release, for example 2.0.0. |
runtime | "fruitful-page-plugin@1" | yes | Module contract id for transform.hooks. It fixes the transform entry points (normalize, materializeRecords, buildView), so the manifest never restates them. |
files | string[] | yes | Every file shipped in the package, as package-relative paths. Input to the release digest; a path referenced anywhere in the manifest must appear here. |
capture | capture | yes | What can be followed, how each page is captured, and how the extract is produced. |
transform | transform | yes | The code that turns an extract into records, and the record Lexicons it may emit. |
render | render | no | How the root record becomes an Activity item. Required for follow and save feeds; absent for enrichment-only packages. |
focus | focusPolicy[] | no | Browser-extension policies applied on the live site when a listed feed is followed. Independent of the capture pipeline; never runs package code. |
Example:
{
"schema": 3,
"kind": "page-plugin",
"name": "com.linkedin.posts",
"version": "1.8.0",
"runtime": "fruitful-page-plugin@1",
"files": [
"linkedin-page-plugin.js",
"linkedin-capture-hooks.js",
"presentation/compact.surface.json",
"presentation/reader.surface.json"
],
"capture": {
"routes": [
{
"id": "newsfeed",
"kind": "collection",
"feed": {
"name": "LinkedIn Newsfeed",
"schedule": {
"cron": "0 0 */4 * * *",
"label": "Every 4 hours",
"runOn": "desktop",
"captureOnFollow": true
},
"explore": {
"group": "feeds",
"platform": "LinkedIn",
"targets": [
{
"id": "linkedin-newsfeed",
"title": "LinkedIn Newsfeed",
"url": "https://www.linkedin.com/feed/"
}
]
}
},
"urls": [
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/feed"
}
},
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/feed/*"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/feed"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/feed/*"
}
}
],
"runsOn": [
"desktop"
],
"login": "linkedin",
"ready": {
"selector": "[data-testid=\"mainFeed\"] div[role=\"listitem\"]:has([componentkey*='-replaceableCommentTools'])",
"timeoutMs": 20000,
"count": 1
},
"scroll": {
"kind": "infinite",
"container": "[data-testid=\"mainFeed\"]",
"item": "div[role=\"listitem\"]:has([componentkey*='-replaceableCommentTools'])",
"minItems": 60,
"maxAttempts": 25
},
"binding": "newsfeed"
},
{
"id": "company-posts",
"kind": "collection",
"feed": {
"name": "LinkedIn Company Posts",
"schedule": {
"cron": "0 0 9 * * *",
"label": "Daily",
"runOn": "desktop",
"captureOnFollow": true
},
"explore": {
"group": "people-companies",
"platform": "LinkedIn",
"targets": [
{
"id": "elevenlabs-linkedin-posts",
"title": "ElevenLabs LinkedIn Posts",
"url": "https://www.linkedin.com/company/elevenlabs/posts/",
"input": {
"label": "Company slug",
"placeholder": "elevenlabs",
"urlTemplate": "https://www.linkedin.com/company/{value}/posts/",
"valuePattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,99}$"
}
}
]
}
},
"urls": [
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/company/:slug/posts"
}
},
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/company/:slug/posts/"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/company/:slug/posts"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/company/:slug/posts/"
}
}
],
"runsOn": [
"desktop"
],
"login": "linkedin",
"ready": {
"selector": ".feed-shared-update-v2",
"timeoutMs": 20000,
"count": 1
},
"on": {
"afterReady": {
"hook": "prepareCompanyPosts",
"onError": "continue"
}
},
"scroll": {
"kind": "finite",
"maxAttempts": 5
},
"binding": "classic"
},
{
"id": "profile-posts",
"kind": "collection",
"feed": {
"name": "LinkedIn Profile Posts",
"schedule": {
"cron": "0 0 9 * * *",
"label": "Daily",
"runOn": "desktop",
"captureOnFollow": true
},
"explore": {
"group": "people-companies",
"platform": "LinkedIn",
"targets": [
{
"id": "satya-nadella-linkedin-posts",
"title": "Satya Nadella LinkedIn Posts",
"url": "https://www.linkedin.com/in/satyanadella/recent-activity/all/",
"input": {
"label": "Profile slug",
"placeholder": "satyanadella",
"urlTemplate": "https://www.linkedin.com/in/{value}/recent-activity/all/",
"valuePattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,99}$"
}
}
]
}
},
"urls": [
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/in/:slug/recent-activity"
}
},
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/in/:slug/recent-activity/*"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/in/:slug/recent-activity"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/in/:slug/recent-activity/*"
}
}
],
"runsOn": [
"desktop"
],
"login": "linkedin",
"ready": {
"selector": ".feed-shared-update-v2",
"timeoutMs": 20000,
"count": 1
},
"scroll": {
"kind": "finite",
"maxAttempts": 5
},
"binding": "classic"
}
],
"logins": [
{
"id": "linkedin",
"name": "LinkedIn",
"reason": "LinkedIn captures need a logged-in browser session.",
"checkUrl": "https://www.linkedin.com/in/me/",
"signInUrl": "https://www.linkedin.com/login",
"signedIn": {
"selectors": [
"[data-testid=\"mainFeed\"]",
"[componentkey^=\"com.linkedin.sdui.profile.card.ref\"]",
"section[data-view-name=\"profile-card\"]"
]
},
"signedOut": {
"reason": "LinkedIn redirected to a sign-in or checkpoint page.",
"urls": [
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/uas/login*"
}
},
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/checkpoint/*"
}
},
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/login*"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/uas/login*"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/checkpoint/*"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/login*"
}
}
],
"html": [
"id=\"rememberme-div\"",
"action=\"/checkpoint/rm/login-submit\""
]
}
}
],
"hooks": {
"file": "linkedin-capture-hooks.js",
"exports": [
{
"name": "prepareCompanyPosts",
"capabilities": [
"browser.dom.read",
"browser.dom.click"
]
}
]
},
"bindings": [
{
"id": "classic",
"package": "com.linkedin.posts.binding.classic@^2.1.0"
},
{
"id": "newsfeed",
"package": "com.linkedin.posts.binding.newsfeed@^2.1.0"
}
]
},
"transform": {
"hooks": {
"file": "linkedin-page-plugin.js"
},
"records": [
"app.fruitful.identity.linkedinAccount@^1.0.0",
"app.fruitful.content.linkedinPost@^1.0.0",
"app.fruitful.activity.repost@^1.0.0"
]
},
"render": {
"root": "app.fruitful.content.linkedinPost",
"view": "app.fruitful.feed.linkedin.defs@^1.0.0#postView",
"occurredAt": "/publishedAt",
"headline": {
"path": "/text",
"default": "LinkedIn post"
},
"surfaces": {
"compact": "presentation/compact.surface.json",
"expanded": "presentation/reader.surface.json"
},
"actions": [
{
"key": "linkedin-browser",
"label": "LinkedIn",
"view": "fruitful.browser.v1",
"url": "/record/url",
"rel": [
"canonical"
]
}
]
},
"focus": [
{
"id": "newsfeed-focus",
"feeds": [
"newsfeed"
],
"urls": [
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/feed"
}
},
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/feed/*"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/feed"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/feed/*"
}
}
],
"hide": [
"www.linkedin.com##aside.scaffold-layout__aside",
"linkedin.com##aside.scaffold-layout__aside",
"www.linkedin.com##.feed-right-rail",
"linkedin.com##.feed-right-rail",
"www.linkedin.com##[data-test-id=\"feed-right-rail\"]",
"linkedin.com##[data-test-id=\"feed-right-rail\"]",
"www.linkedin.com##section:has(h2:has-text(/^LinkedIn News$/i))",
"linkedin.com##section:has(h2:has-text(/^LinkedIn News$/i))"
],
"replace": {
"selector": "[data-testid=\"mainFeed\"]",
"with": "panel",
"title": "Read your LinkedIn updates in Fruitful"
}
}
]
}capture
The capture block. Routes are the unit; a route's feed is how it is offered to people, a route's detailOf is which master it details; hooks are the only capture-time code; bindings configure the platform extractor engines that turn a page into records.
| Field | Type | Required | Description |
|---|---|---|---|
routes | route[] | yes | Every page this package knows how to capture, one route each. A route says what it yields (kind), how it is reached (feed for people, detailOf for entries of a record type, neither for a pasted URL), and how the page becomes a record (binding). URL resolution across routes must be unambiguous. |
logins | login[] | no | Signed-in browser sessions a route may require. Referenced from routes by id. |
hooks | captureHooks | no | The capture-hook module and the capabilities each export is granted. One of the two places code may appear. |
bindings | bindingDependency[] | yes | Binding packages this package pins, each with the alias routes use. A binding configures one platform extractor engine (dom, reader, media, rss, http) for one site; first-party bindings such as app.fruitful.binding.reader are pinned like any other. |
Example:
{
"routes": [
{
"id": "newsfeed",
"kind": "collection",
"feed": {
"name": "LinkedIn Newsfeed",
"schedule": {
"cron": "0 0 */4 * * *",
"label": "Every 4 hours",
"runOn": "desktop",
"captureOnFollow": true
},
"explore": {
"group": "feeds",
"platform": "LinkedIn",
"targets": [
{
"id": "linkedin-newsfeed",
"title": "LinkedIn Newsfeed",
"url": "https://www.linkedin.com/feed/"
}
]
}
},
"urls": [
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/feed"
}
},
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/feed/*"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/feed"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/feed/*"
}
}
],
"runsOn": [
"desktop"
],
"login": "linkedin",
"ready": {
"selector": "[data-testid=\"mainFeed\"] div[role=\"listitem\"]:has([componentkey*='-replaceableCommentTools'])",
"timeoutMs": 20000,
"count": 1
},
"scroll": {
"kind": "infinite",
"container": "[data-testid=\"mainFeed\"]",
"item": "div[role=\"listitem\"]:has([componentkey*='-replaceableCommentTools'])",
"minItems": 60,
"maxAttempts": 25
},
"binding": "newsfeed"
},
{
"id": "company-posts",
"kind": "collection",
"feed": {
"name": "LinkedIn Company Posts",
"schedule": {
"cron": "0 0 9 * * *",
"label": "Daily",
"runOn": "desktop",
"captureOnFollow": true
},
"explore": {
"group": "people-companies",
"platform": "LinkedIn",
"targets": [
{
"id": "elevenlabs-linkedin-posts",
"title": "ElevenLabs LinkedIn Posts",
"url": "https://www.linkedin.com/company/elevenlabs/posts/",
"input": {
"label": "Company slug",
"placeholder": "elevenlabs",
"urlTemplate": "https://www.linkedin.com/company/{value}/posts/",
"valuePattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,99}$"
}
}
]
}
},
"urls": [
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/company/:slug/posts"
}
},
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/company/:slug/posts/"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/company/:slug/posts"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/company/:slug/posts/"
}
}
],
"runsOn": [
"desktop"
],
"login": "linkedin",
"ready": {
"selector": ".feed-shared-update-v2",
"timeoutMs": 20000,
"count": 1
},
"on": {
"afterReady": {
"hook": "prepareCompanyPosts",
"onError": "continue"
}
},
"scroll": {
"kind": "finite",
"maxAttempts": 5
},
"binding": "classic"
},
{
"id": "profile-posts",
"kind": "collection",
"feed": {
"name": "LinkedIn Profile Posts",
"schedule": {
"cron": "0 0 9 * * *",
"label": "Daily",
"runOn": "desktop",
"captureOnFollow": true
},
"explore": {
"group": "people-companies",
"platform": "LinkedIn",
"targets": [
{
"id": "satya-nadella-linkedin-posts",
"title": "Satya Nadella LinkedIn Posts",
"url": "https://www.linkedin.com/in/satyanadella/recent-activity/all/",
"input": {
"label": "Profile slug",
"placeholder": "satyanadella",
"urlTemplate": "https://www.linkedin.com/in/{value}/recent-activity/all/",
"valuePattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,99}$"
}
}
]
}
},
"urls": [
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/in/:slug/recent-activity"
}
},
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/in/:slug/recent-activity/*"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/in/:slug/recent-activity"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/in/:slug/recent-activity/*"
}
}
],
"runsOn": [
"desktop"
],
"login": "linkedin",
"ready": {
"selector": ".feed-shared-update-v2",
"timeoutMs": 20000,
"count": 1
},
"scroll": {
"kind": "finite",
"maxAttempts": 5
},
"binding": "classic"
}
],
"logins": [
{
"id": "linkedin",
"name": "LinkedIn",
"reason": "LinkedIn captures need a logged-in browser session.",
"checkUrl": "https://www.linkedin.com/in/me/",
"signInUrl": "https://www.linkedin.com/login",
"signedIn": {
"selectors": [
"[data-testid=\"mainFeed\"]",
"[componentkey^=\"com.linkedin.sdui.profile.card.ref\"]",
"section[data-view-name=\"profile-card\"]"
]
},
"signedOut": {
"reason": "LinkedIn redirected to a sign-in or checkpoint page.",
"urls": [
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/uas/login*"
}
},
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/checkpoint/*"
}
},
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/login*"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/uas/login*"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/checkpoint/*"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/login*"
}
}
],
"html": [
"id=\"rememberme-div\"",
"action=\"/checkpoint/rm/login-submit\""
]
}
}
],
"hooks": {
"file": "linkedin-capture-hooks.js",
"exports": [
{
"name": "prepareCompanyPosts",
"capabilities": [
"browser.dom.read",
"browser.dom.click"
]
}
]
},
"bindings": [
{
"id": "classic",
"package": "com.linkedin.posts.binding.classic@^2.1.0"
},
{
"id": "newsfeed",
"package": "com.linkedin.posts.binding.newsfeed@^2.1.0"
}
]
}feed
How a route is offered to people. A collection with a feed is followed on its schedule; an item with a feed is saved once. Routes without a feed never appear in Explore or Activity.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Display name shown in intake, Registry, and Explore. |
schedule | schedule | no | Capture cadence and placement applied when a person follows. Only meaningful on collection routes. |
explore | explore | no | How the feed appears in Explore. Omit to keep the feed out of the catalog. |
Example:
{
"name": "LinkedIn Newsfeed",
"schedule": {
"cron": "0 0 */4 * * *",
"label": "Every 4 hours",
"runOn": "desktop",
"captureOnFollow": true
},
"explore": {
"group": "feeds",
"platform": "LinkedIn",
"targets": [
{
"id": "linkedin-newsfeed",
"title": "LinkedIn Newsfeed",
"url": "https://www.linkedin.com/feed/"
}
]
}
}schedule
Defaults applied to the page created when a user follows a feed.
| Field | Type | Required | Description |
|---|---|---|---|
cron | string | yes | Six-field cron expression for recurring captures. |
label | string | no | Human label for the cadence shown in Explore and intake, for example Every 4 hours. |
runOn | "desktop" | "cloud" | no | Where captures run. desktop uses the user's signed-in browser via the Desktop app; cloud uses the screenshot service. |
captureOnFollow | boolean | no | Whether to capture immediately when the feed is followed. |
Example:
{
"cron": "0 0 */4 * * *",
"label": "Every 4 hours",
"runOn": "desktop",
"captureOnFollow": true
}explore
Explore catalog placement for a feed.
| Field | Type | Required | Description |
|---|---|---|---|
group | "feeds" | "people-companies" | "communities" | "media" | yes | Explore section the targets are listed under. |
platform | string | yes | Platform label on the Explore card, for example LinkedIn. |
targets | exploreTarget[] | yes | Concrete URLs a user can pick, optionally parameterised. |
Example:
{
"group": "feeds",
"platform": "LinkedIn",
"targets": [
{
"id": "linkedin-newsfeed",
"title": "LinkedIn Newsfeed",
"url": "https://www.linkedin.com/feed/"
}
]
}exploreTarget
One Explore card.
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Stable target id, kebab-case. |
title | string | yes | Card title. |
url | string (uri) | yes | Example or default URL. Must match the feed's route patterns. |
input | exploreInput | no | Present when the user supplies a value (a handle, a slug) that is substituted into a URL template. |
Example:
{
"id": "linkedin-newsfeed",
"title": "LinkedIn Newsfeed",
"url": "https://www.linkedin.com/feed/"
}exploreInput
A single user-supplied value that builds the target URL.
| Field | Type | Required | Description |
|---|---|---|---|
label | string | yes | Field label, for example Company slug. |
placeholder | string | yes | Example value shown in the field. |
urlTemplate | string | yes | URL with a {value} placeholder. |
valuePattern | string | no | Regular expression the raw value must match before substitution. |
prefixToStrip | string | no | Prefix removed from the raw value before substitution, for example @. |
Example:
{
"label": "Company slug",
"placeholder": "elevenlabs",
"urlTemplate": "https://www.linkedin.com/company/{value}/posts/",
"valuePattern": "^[A-Za-z0-9][A-Za-z0-9-]{0,99}$"
}login
A signed-in browser session and how to recognise it.
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Login id referenced by routes. |
name | string | yes | Display name, for example LinkedIn. |
reason | string | no | Shown to the user when explaining why sign-in is needed. |
checkUrl | string (uri) | yes | URL loaded to test the session. |
signInUrl | string (uri) | yes | URL opened for the user to sign in. |
signedIn | sessionMatcher | no | Positive match: the session is ready when this matches the check page. |
signedOut | sessionMatcher | no | Negative match: the session is missing when this matches the final URL or HTML. |
Example:
{
"id": "linkedin",
"name": "LinkedIn",
"reason": "LinkedIn captures need a logged-in browser session.",
"checkUrl": "https://www.linkedin.com/in/me/",
"signInUrl": "https://www.linkedin.com/login",
"signedIn": {
"selectors": [
"[data-testid=\"mainFeed\"]",
"[componentkey^=\"com.linkedin.sdui.profile.card.ref\"]",
"section[data-view-name=\"profile-card\"]"
]
},
"signedOut": {
"reason": "LinkedIn redirected to a sign-in or checkpoint page.",
"urls": [
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/uas/login*"
}
},
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/checkpoint/*"
}
},
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/login*"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/uas/login*"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/checkpoint/*"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/login*"
}
}
],
"html": [
"id=\"rememberme-div\"",
"action=\"/checkpoint/rm/login-submit\""
]
}
}sessionMatcher
Any of the listed conditions matching counts as a match.
| Field | Type | Required | Description |
|---|---|---|---|
reason | string | no | Message shown when this matcher fires. |
urls | urlPattern[] | no | URL patterns tested against the final URL. |
selectors | string[] | no | CSS selectors tested against the page. |
html | string[] | no | Literal substrings tested against the page HTML. |
Example:
{
"selectors": [
"[data-testid=\"mainFeed\"]",
"[componentkey^=\"com.linkedin.sdui.profile.card.ref\"]",
"section[data-view-name=\"profile-card\"]"
]
}captureHooks
The capture-hook module. Every export is a function the platform calls at a named lifecycle point with exactly the capabilities declared here; the declaration becomes the sandbox import table.
| Field | Type | Required | Description |
|---|---|---|---|
file | string | yes | Package-relative path to the self-contained hook bundle. Must appear in files. |
stdlib | string (known: url, intl, base64)[] | no | Host-provided pure modules linked into the sandbox. Declared, then linked; never inferred from the bundle. |
exports | hookExport[] | no | Capability grants per export. Every hook referenced by a route must be listed; an unlisted export gets an empty import table. Publish validation checks each name exists in the module. |
Example:
{
"file": "linkedin-capture-hooks.js",
"exports": [
{
"name": "prepareCompanyPosts",
"capabilities": [
"browser.dom.read",
"browser.dom.click"
]
}
]
}hookExport
One exported hook and the authority it is granted.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Export name in the hook module. |
capabilities | ("browser.dom.read" | "browser.dom.click" | "browser.dom.type")[] | yes | Capability ids; each maps to a fixed set of page operations in the SafePage import table. |
Example:
{
"name": "prepareCompanyPosts",
"capabilities": [
"browser.dom.read",
"browser.dom.click"
]
}route
One page the package can capture. kind says what the route yields: a collection (the master: many entries that get ids and become Activity, re-observed on a schedule) or an item (the detail: one thing that stands alone and enriches an entry). binding says how the page becomes a record: which pinned binding, and through it which platform extractor engine, runs over it.
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Stable route identity, kebab-case. Pinned into installs and provenance when the route carries a feed; never renamed. |
kind | "collection" | "item" | yes | collection: the page lists many entries; each gets a stable id and new ones become Activity. item: the page is one thing (an article, a post, a video) that stands alone and enriches an entry. |
feed | feed | no | Offer this route to people. Present on routes that appear in Explore and can be followed or saved. |
detailOf | recordSource | no | The master this item is the detail of: each entry of the named record type has a URL at the given pointer, and that URL is captured through this route, one hop, bounded by the platform. |
urls | urlPattern[] | no | URL patterns this route handles. Required unless the route is the detail of a record type, where it may constrain which entry URLs are accepted. |
runsOn | ("desktop" | "cloud")[] | yes | Where this route may execute. Authoring tooling can run any route and is not listed. |
login | string | no | Id of a login that must be ready before capture. |
load | loadOptions | no | Navigation wait and timeout. Defaults: domcontentloaded, 45000 ms. |
ready | readiness | no | Selector the page must satisfy before hooks and scrolling run. |
on | lifecycle | no | Capture hooks attached to lifecycle points. Omission is the canonical empty. |
scroll | scroll | no | Scroll strategy applied before the snapshot. |
binding | string | yes | Alias from capture.bindings. The binding's extractor engine decides what the page becomes: a dom binding emits the transform's input, a reader binding emits a webDocument, a media binding emits a media record. Collections need a binding that emits transform.input; reader and media bindings are for items. |
Example:
{
"id": "newsfeed",
"kind": "collection",
"feed": {
"name": "LinkedIn Newsfeed",
"schedule": {
"cron": "0 0 */4 * * *",
"label": "Every 4 hours",
"runOn": "desktop",
"captureOnFollow": true
},
"explore": {
"group": "feeds",
"platform": "LinkedIn",
"targets": [
{
"id": "linkedin-newsfeed",
"title": "LinkedIn Newsfeed",
"url": "https://www.linkedin.com/feed/"
}
]
}
},
"urls": [
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/feed"
}
},
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/feed/*"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/feed"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/feed/*"
}
}
],
"runsOn": [
"desktop"
],
"login": "linkedin",
"ready": {
"selector": "[data-testid=\"mainFeed\"] div[role=\"listitem\"]:has([componentkey*='-replaceableCommentTools'])",
"timeoutMs": 20000,
"count": 1
},
"scroll": {
"kind": "infinite",
"container": "[data-testid=\"mainFeed\"]",
"item": "div[role=\"listitem\"]:has([componentkey*='-replaceableCommentTools'])",
"minItems": 60,
"maxAttempts": 25
},
"binding": "newsfeed"
}loadOptions
Navigation settings.
| Field | Type | Required | Description |
|---|---|---|---|
waitUntil | "domcontentloaded" | "load" | no | Navigation event to wait for. |
timeoutMs | integer | no | Navigation timeout in milliseconds. |
readiness
The condition that marks the page as loaded enough to capture.
| Field | Type | Required | Description |
|---|---|---|---|
selector | string | yes | CSS selector to wait for. |
count | integer | no | Minimum number of matches. |
timeoutMs | integer | no | How long to wait before the route fails. |
Example:
{
"selector": "[data-testid=\"mainFeed\"] div[role=\"listitem\"]:has([componentkey*='-replaceableCommentTools'])",
"timeoutMs": 20000,
"count": 1
}lifecycle
Hook attachments by capture lifecycle point. The point vocabulary is the platform's; unknown points are rejected.
| Field | Type | Required | Description |
|---|---|---|---|
afterLoad | hookRef | no | After navigation completes, before the readiness wait. |
afterReady | hookRef | no | After the readiness selector is satisfied, before scrolling. |
beforeSnapshot | hookRef | no | After scrolling settles, immediately before the snapshot. |
Example:
{
"afterReady": {
"hook": "prepareCompanyPosts",
"onError": "continue"
}
}hookRef
A reference to a capture hook export plus per-usage failure policy.
| Field | Type | Required | Description |
|---|---|---|---|
hook | string | yes | Export name declared in capture.hooks.exports. |
onError | "fail" | "continue" | no | fail aborts the capture; continue proceeds to the snapshot. Per usage because the same hook can be load-bearing on one route and best-effort on another. |
Example:
{
"hook": "prepareCompanyPosts",
"onError": "continue"
}scroll
Scroll strategy. kind selects the algorithm; container and item are required for infinite, stitching and paginate, next is required for paginate, and all are ignored for finite (enforced by the registry).
| Field | Type | Required | Description |
|---|---|---|---|
kind | "finite" | "infinite" | "stitching" | "paginate" | yes | finite scrolls to the bottom a bounded number of times; infinite scrolls a container until item growth stops; stitching also concatenates virtualised items that leave the DOM; paginate follows the next link and stitches each page's items. |
container | string | no | CSS selector of the scrolling container. |
item | string | no | CSS selector of one feed item, used to measure growth. |
minItems | integer | no | Stop once this many items are present. |
maxAttempts | integer | no | Upper bound on scroll iterations. |
delayMs | integer | no | Pause between iterations. |
itemGrowthTimeoutMs | integer | no | Give up when no new items appear within this window. |
dedupeAttribute | string | no | stitching only: attribute that identifies an item across virtualisation. |
next | string | no | paginate only: CSS selector of the next-page link. Its href is followed until it is absent, minItems is reached, or maxPages is hit. |
maxPages | integer | no | paginate only: upper bound on pages visited, the first page included. Defaults to 5. |
Example:
{
"kind": "infinite",
"container": "[data-testid=\"mainFeed\"]",
"item": "div[role=\"listitem\"]:has([componentkey*='-replaceableCommentTools'])",
"minItems": 60,
"maxAttempts": 25
}urlPattern
A URLPattern plus sibling constraints. The pattern wrapper is deliberate: exclude, groups, and params are siblings, not children, of the pattern.
| Field | Type | Required | Description |
|---|---|---|---|
pattern | urlPatternInit | yes | URLPattern init; omitted components match anything. |
exclude | urlPatternInit[] | no | Patterns that reject a URL even when pattern matches. |
groups | pathnameGroup[] | no | Constraints on named pathname groups such as :handle. |
params | searchParam[] | no | Constraints on query parameters. |
Example:
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/feed"
}
}urlPatternInit
URLPattern components, each in URLPattern syntax.
| Field | Type | Required | Description |
|---|---|---|---|
protocol | string | no | |
username | string | no | |
password | string | no | |
hostname | string | no | |
port | string | no | |
pathname | string | no | |
search | string | no | |
hash | string | no | |
baseURL | string (uri) | no |
Example:
{
"hostname": "linkedin.com",
"pathname": "/feed"
}pathnameGroup
A constraint on one named pathname group.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Group name without the leading colon. |
regex | string | no | Regular expression the group value must match. |
oneOf | string[] | no | Allowed values. |
caseInsensitive | boolean | no | Compare ignoring case. |
Example:
{
"name": "handle",
"regex": "^[a-zA-Z_][a-zA-Z0-9_]{0,20}$"
}searchParam
A constraint on one query parameter.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Parameter name. |
required | boolean | no | Whether the parameter must be present. |
regex | string | no | Regular expression the value must match. |
oneOf | string[] | no | Allowed values. |
caseInsensitive | boolean | no | Compare ignoring case. |
transform
The transform block: the input def, the module that turns an extract into records, and the record Lexicons it may emit.
| Field | Type | Required | Description |
|---|---|---|---|
input | string | no | The Lexicon def the transform consumes, as nsid@range#def. Optional when every dom, rss or http binding the package pins emits the same def, in which case it is that def; required when they differ, and then the transform must export normalize for every binding whose emits is not this def. |
hooks | transformHooks | yes | The transform module. The second of the two places code may appear. |
records | string[] | yes | Record Lexicon pins as nsid@range. Identity is recordRef.uri and links are ref-typed properties, both read from the Lexicon; nothing is restated here. |
Example:
{
"hooks": {
"file": "linkedin-page-plugin.js"
},
"records": [
"app.fruitful.identity.linkedinAccount@^1.0.0",
"app.fruitful.content.linkedinPost@^1.0.0",
"app.fruitful.activity.repost@^1.0.0"
]
}transformHooks
The transform module. Entry points are fixed by runtime: normalize (only when a binding emits a def other than transform.input), materializeRecords, buildView.
| Field | Type | Required | Description |
|---|---|---|---|
file | string | yes | Package-relative path to the self-contained transform bundle. Must appear in files and differ from capture.hooks.file. |
stdlib | string (known: url, intl, base64)[] | no | Host-provided pure modules linked into the sandbox. |
Example:
{
"file": "linkedin-page-plugin.js"
}render
The render block. Declares which record is the Activity root and how the trusted catalog presents it. Never contains code.
| Field | Type | Required | Description |
|---|---|---|---|
root | string (nsid) | yes | NSID of the record Lexicon that becomes an Activity item. Must appear in transform.records. |
view | string | yes | View Lexicon def buildView must produce, as nsid@range#def. |
occurredAt | string | no | JSON Pointer into the root record for the item's timestamp. Omit to use the capture time; when present and the path is absent from a record, capture time is the fallback. |
headline | headline | yes | Where the Activity shell headline comes from. |
surfaces | surfaces | yes | A2UI surface templates. Each carries its own catalog id; they must agree. |
actions | action[] | no | Alternate ways to open the item, resolved against the validated View. |
Example:
{
"root": "app.fruitful.content.linkedinPost",
"view": "app.fruitful.feed.linkedin.defs@^1.0.0#postView",
"occurredAt": "/publishedAt",
"headline": {
"path": "/text",
"default": "LinkedIn post"
},
"surfaces": {
"compact": "presentation/compact.surface.json",
"expanded": "presentation/reader.surface.json"
},
"actions": [
{
"key": "linkedin-browser",
"label": "LinkedIn",
"view": "fruitful.browser.v1",
"url": "/record/url",
"rel": [
"canonical"
]
}
]
}headline
Headline source for the Activity shell. With path, the record value is used and default is the fallback; without path, default is the static headline.
| Field | Type | Required | Description |
|---|---|---|---|
path | string | no | JSON Pointer into the root record. Omit for a static headline. |
default | string | yes | Fallback when path is absent or empty, or the static headline when there is no path. |
Example:
{
"path": "/text",
"default": "LinkedIn post"
}surfaces
Package-authored A2UI v0.9.1 surface templates.
| Field | Type | Required | Description |
|---|---|---|---|
compact | string | yes | Package-relative path to the compact surface JSON. |
expanded | string | yes | Package-relative path to the expanded (reader) surface JSON. |
Example:
{
"compact": "presentation/compact.surface.json",
"expanded": "presentation/reader.surface.json"
}action
A resource the item can be opened as, for example the original website.
| Field | Type | Required | Description |
|---|---|---|---|
key | string | yes | Stable key, kebab-case. |
label | string | yes | Button label. |
view | string | yes | Trusted view type, for example fruitful.browser.v1 or fruitful.webReader.v1. |
url | string | yes | JSON Pointer into the View selecting the resource URI. |
mediaType | string | no | MIME type of the resource when known. |
rel | string (known: canonical, replies)[] | no | Relationships from the item to the resource; known values or absolute extension URIs. |
Example:
{
"key": "linkedin-browser",
"label": "LinkedIn",
"view": "fruitful.browser.v1",
"url": "/record/url",
"rel": [
"canonical"
]
}focusPolicy
A browser-extension Focus policy. Active only while a listed feed is followed by an installed package.
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Policy id, kebab-case. |
feeds | string[] | yes | Ids of routes whose feed being followed activates this policy. |
urls | urlPattern[] | yes | Browsing URLs the policy applies to. Distinct from capture routes. |
hide | string[] | no | Adblock-syntax cosmetic rules applied by the extension's cosmetic engine. |
replace | replacement | no | Replace a distraction surface with a Fruitful panel or interstitial. |
Example:
{
"id": "newsfeed-focus",
"feeds": [
"newsfeed"
],
"urls": [
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/feed"
}
},
{
"pattern": {
"hostname": "linkedin.com",
"pathname": "/feed/*"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/feed"
}
},
{
"pattern": {
"hostname": "*.linkedin.com",
"pathname": "/feed/*"
}
}
],
"hide": [
"www.linkedin.com##aside.scaffold-layout__aside",
"linkedin.com##aside.scaffold-layout__aside",
"www.linkedin.com##.feed-right-rail",
"linkedin.com##.feed-right-rail",
"www.linkedin.com##[data-test-id=\"feed-right-rail\"]",
"linkedin.com##[data-test-id=\"feed-right-rail\"]",
"www.linkedin.com##section:has(h2:has-text(/^LinkedIn News$/i))",
"linkedin.com##section:has(h2:has-text(/^LinkedIn News$/i))"
],
"replace": {
"selector": "[data-testid=\"mainFeed\"]",
"with": "panel",
"title": "Read your LinkedIn updates in Fruitful"
}
}replacement
Replaces the matched element in the live page.
| Field | Type | Required | Description |
|---|---|---|---|
selector | string | yes | CSS selector of the element to replace. |
with | "panel" | "interstitial" | yes | panel replaces a feed with a Fruitful panel; interstitial replaces a whole surface and pauses media inside it. |
title | string | no | Panel title. |
description | string | no | Panel body text. |
Example:
{
"selector": "[data-testid=\"mainFeed\"]",
"with": "panel",
"title": "Read your LinkedIn updates in Fruitful"
}recordSource
Which master an item route is the detail of: the record type whose entries carry the URL, read off each root record after the transform runs.
| Field | Type | Required | Description |
|---|---|---|---|
record | string (nsid) | yes | NSID of the record Lexicon whose instances carry the URL. Must be the render root, since the frontier is read off Activity items. |
url | string | yes | JSON Pointer into the record selecting a URI string, for example /destinationUrl. |
Example:
{
"record": "app.fruitful.feed.hackerNews.submission",
"url": "/destinationUrl"
}bindingDependency
A pinned binding package, addressed from routes by alias.
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Alias used in route.binding. |
package | string | yes | Binding package pin as name@range, for example com.linkedin.posts.binding.newsfeed@^2.1.0 or app.fruitful.binding.reader@^1.0.0. |
Example:
{
"id": "classic",
"package": "com.linkedin.posts.binding.classic@^2.1.0"
}