@sockethub/platform-metadata 1.0.1-alpha.9 → 1.0.1-beta.1
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/README.md +72 -4
- package/dist/index.d.ts +55 -0
- package/dist/index.js +6657 -3355
- package/dist/index.js.map +66 -62
- package/dist/resolvers.d.ts +231 -0
- package/dist/schema.d.ts +25 -0
- package/package.json +7 -6
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site-specific URL resolution for pages that don't serve useful Open Graph
|
|
3
|
+
* data to scrapers.
|
|
4
|
+
*
|
|
5
|
+
* Some large platforms return a generic banner image (X/Twitter), serve
|
|
6
|
+
* their Open Graph data only to recognized embed crawlers (Reddit), or
|
|
7
|
+
* hide post media behind a login (Facebook):
|
|
8
|
+
*
|
|
9
|
+
* - X/Twitter → FxTwitter's JSON API (api.fxtwitter.com), built for embed
|
|
10
|
+
* previews, returns the tweet text plus direct photo/video URLs.
|
|
11
|
+
* - Reddit → the regular scrape works, but only when presented with an
|
|
12
|
+
* embed-crawler user agent (see COMPAT_USER_AGENT in index.ts).
|
|
13
|
+
* - Facebook → same crawler-UA gating as Reddit, plus its video titles
|
|
14
|
+
* arrive polluted with localized engagement stats that need stripping.
|
|
15
|
+
*
|
|
16
|
+
* These are pure URL matchers/mappers — the platform decides what to do
|
|
17
|
+
* with the resolution (call a JSON API vs. pick a scrape user agent).
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Match an X/Twitter status URL and return the FxTwitter API URL for it,
|
|
21
|
+
* or null when the URL isn't a status page (profiles, search, home feed —
|
|
22
|
+
* those go through the normal OG scrape).
|
|
23
|
+
*
|
|
24
|
+
* Handles `/<user>/status/<id>` and the `/i/web/status/<id>` share form.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveTwitterStatus(url: string): string | null;
|
|
27
|
+
/** Resolve supported YouTube video URLs to the official oEmbed endpoint. */
|
|
28
|
+
export declare function resolveYouTubeOEmbed(url: string): string | null;
|
|
29
|
+
export interface YouTubeOEmbed {
|
|
30
|
+
title: string;
|
|
31
|
+
provider_name?: string;
|
|
32
|
+
thumbnail_url: string;
|
|
33
|
+
thumbnail_width?: number;
|
|
34
|
+
thumbnail_height?: number;
|
|
35
|
+
}
|
|
36
|
+
/** Validate the subset of YouTube's external oEmbed payload we consume. */
|
|
37
|
+
export declare function parseYouTubeOEmbed(value: unknown): YouTubeOEmbed | null;
|
|
38
|
+
/** Map a validated YouTube oEmbed thumbnail to the platform image shape. */
|
|
39
|
+
export declare function youtubeOEmbedImage(embed?: YouTubeOEmbed): PageObject["image"] | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Extract YouTube's full video description from the player bootstrap JSON.
|
|
42
|
+
* This data is embedded in the watch HTML while the Open Graph description is
|
|
43
|
+
* intentionally truncated. Return undefined when the undocumented payload
|
|
44
|
+
* shape changes so callers can safely fall back to Open Graph metadata.
|
|
45
|
+
*/
|
|
46
|
+
export declare function extractYouTubeDescription(html: string): string | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* True for Facebook URLs (including fb.watch short links). Facebook serves
|
|
49
|
+
* an unrecognized scraper a login interstitial instead of the post, but
|
|
50
|
+
* serves recognized link-preview crawlers the post's Open Graph data —
|
|
51
|
+
* title, caption, and the video/photo thumbnail — so these URLs scrape
|
|
52
|
+
* with COMPAT_USER_AGENT (see index.ts), like Reddit.
|
|
53
|
+
*/
|
|
54
|
+
export declare function isFacebookUrl(url: string): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Remove the engagement-stats segment Facebook prepends to video titles:
|
|
57
|
+
* "2.2M views · 21K reactions | <video title> | <author>". The stats are
|
|
58
|
+
* localized to the *scraping server's* geo-IP language, so match their
|
|
59
|
+
* shape rather than any particular wording: a leading pipe-delimited
|
|
60
|
+
* segment that either contains a digit and the "·" separator, or — when
|
|
61
|
+
* Facebook reports a single count, e.g. "330.213 Aufrufe | Reel by X",
|
|
62
|
+
* "347K views | …" or "2,2 mil. zhlédnutí | …" — is a count-shaped number
|
|
63
|
+
* (grouping or decimal separator, magnitude suffix, or at least four
|
|
64
|
+
* digits), an optional abbreviation, and one word. The view label itself
|
|
65
|
+
* cannot be matched: it is whatever Facebook's geo-IP locale produces. The
|
|
66
|
+
* count shape keeps a caption that merely starts with a small number
|
|
67
|
+
* ("10 Goals | Author") intact; only counts below 1,000 with no suffix
|
|
68
|
+
* would leak into the title.
|
|
69
|
+
*/
|
|
70
|
+
export declare function stripFacebookEngagement(title: string | undefined): string | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Facebook's public embed plugin for a post. Unlike the post page, it is
|
|
73
|
+
* meant to be rendered by third-party sites, so it still carries the
|
|
74
|
+
* media thumbnail when the post page withholds its Open Graph image from
|
|
75
|
+
* an unrecognized or datacenter client. Video posts (reels, watch) use
|
|
76
|
+
* the video plugin; everything else the post plugin.
|
|
77
|
+
*/
|
|
78
|
+
export declare function resolveFacebookEmbed(url: string, ogType: string | undefined): string | null;
|
|
79
|
+
/**
|
|
80
|
+
* The media thumbnail from a Facebook embed-plugin page: the first `<img>`
|
|
81
|
+
* served from Facebook's CDN. The plugin renders the post media before
|
|
82
|
+
* the author avatar, so the first CDN image is the thumbnail.
|
|
83
|
+
*/
|
|
84
|
+
export declare function extractFacebookEmbedImage(html: string): string | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* True for Reddit URLs (including redd.it short links). Reddit serves its
|
|
87
|
+
* Open Graph tags — with the post's real preview image — only to
|
|
88
|
+
* recognized embed-crawler user agents; everything else gets a page with
|
|
89
|
+
* no OG data (or an outright 403 from datacenter addresses).
|
|
90
|
+
*/
|
|
91
|
+
export declare function isRedditUrl(url: string): boolean;
|
|
92
|
+
/** Map a canonical Reddit post URL to Reddit's official media-enabled embed. */
|
|
93
|
+
export declare function resolveRedditEmbed(url: string): string | null;
|
|
94
|
+
/** Map a canonical Reddit post URL to old.reddit.com's public JSON endpoint. */
|
|
95
|
+
export declare function resolveRedditJson(url: string): string | null;
|
|
96
|
+
/**
|
|
97
|
+
* Unwrap Reddit's media-viewer URL (`reddit.com/media?url=<image>`) to the
|
|
98
|
+
* image it displays. Reddit serves neither post JSON nor oEmbed for these
|
|
99
|
+
* links (its oEmbed endpoint returns HTTP 400), but the target image is
|
|
100
|
+
* embedded in the link itself, so no network request is needed at all.
|
|
101
|
+
*/
|
|
102
|
+
export declare function resolveRedditMedia(url: string): string | null;
|
|
103
|
+
export interface RedditPost {
|
|
104
|
+
title: string;
|
|
105
|
+
selftext?: string;
|
|
106
|
+
url?: string;
|
|
107
|
+
url_overridden_by_dest?: string;
|
|
108
|
+
secure_media?: {
|
|
109
|
+
reddit_video?: {
|
|
110
|
+
fallback_url?: string;
|
|
111
|
+
width?: number;
|
|
112
|
+
height?: number;
|
|
113
|
+
duration?: number;
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
preview?: {
|
|
117
|
+
images?: Array<{
|
|
118
|
+
source?: {
|
|
119
|
+
url?: string;
|
|
120
|
+
width?: number;
|
|
121
|
+
height?: number;
|
|
122
|
+
};
|
|
123
|
+
}>;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/** Validate and extract the post object from Reddit's listing response. */
|
|
127
|
+
export declare function parseRedditPost(value: unknown): RedditPost | null;
|
|
128
|
+
/** Select only a direct Reddit-hosted image belonging to the post. */
|
|
129
|
+
export declare function redditPostImage(post: RedditPost): PageObject["image"];
|
|
130
|
+
/** Map Reddit's hosted-video payload to the common page video shape. */
|
|
131
|
+
export declare function redditPostVideo(post: RedditPost): PageObject["video"];
|
|
132
|
+
/** Subset of Reddit's oEmbed response used for link previews. */
|
|
133
|
+
export interface RedditOEmbed {
|
|
134
|
+
title?: string;
|
|
135
|
+
author_name?: string;
|
|
136
|
+
provider_name?: string;
|
|
137
|
+
thumbnail_url?: string;
|
|
138
|
+
thumbnail_width?: number;
|
|
139
|
+
thumbnail_height?: number;
|
|
140
|
+
}
|
|
141
|
+
/** Validate the subset of Reddit's untrusted oEmbed JSON that we consume. */
|
|
142
|
+
export declare function parseRedditOEmbed(value: unknown): RedditOEmbed | null;
|
|
143
|
+
/**
|
|
144
|
+
* Convert Reddit's optional oEmbed thumbnail into the platform image shape.
|
|
145
|
+
* A missing or malformed thumbnail deliberately means "no image": Reddit's
|
|
146
|
+
* scraped Open Graph image may be a generic site hero rather than post media.
|
|
147
|
+
*/
|
|
148
|
+
export declare function redditOEmbedImage(embed: RedditOEmbed): PageObject["image"] | undefined;
|
|
149
|
+
/** Keep only Reddit-hosted post media, excluding branding/share-card images. */
|
|
150
|
+
export declare function redditPostImages(images: PageObject["image"]): PageObject["image"] | undefined;
|
|
151
|
+
/**
|
|
152
|
+
* Make scraped plain text readable without flattening intentional structure.
|
|
153
|
+
* Preserve line breaks and paragraphs, but remove HTML indentation and cap
|
|
154
|
+
* excessive vertical whitespace at one blank line.
|
|
155
|
+
*/
|
|
156
|
+
export declare function normalizeDescription(value: string): string;
|
|
157
|
+
/** Subset of the FxTwitter status API response the platform consumes. */
|
|
158
|
+
export interface FxTwitterStatus {
|
|
159
|
+
code: number;
|
|
160
|
+
message?: string;
|
|
161
|
+
tweet?: {
|
|
162
|
+
url?: string;
|
|
163
|
+
text?: string;
|
|
164
|
+
author?: {
|
|
165
|
+
name?: string;
|
|
166
|
+
screen_name?: string;
|
|
167
|
+
};
|
|
168
|
+
media?: {
|
|
169
|
+
photos?: Array<{
|
|
170
|
+
url?: string;
|
|
171
|
+
width?: number;
|
|
172
|
+
height?: number;
|
|
173
|
+
}>;
|
|
174
|
+
videos?: Array<{
|
|
175
|
+
url?: string;
|
|
176
|
+
thumbnail_url?: string;
|
|
177
|
+
width?: number;
|
|
178
|
+
height?: number;
|
|
179
|
+
duration?: number;
|
|
180
|
+
}>;
|
|
181
|
+
};
|
|
182
|
+
article?: {
|
|
183
|
+
title?: string;
|
|
184
|
+
preview_text?: string;
|
|
185
|
+
cover_media?: {
|
|
186
|
+
media_info?: {
|
|
187
|
+
original_img_url?: string;
|
|
188
|
+
original_img_width?: number;
|
|
189
|
+
original_img_height?: number;
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
content?: {
|
|
193
|
+
blocks?: Array<{
|
|
194
|
+
text?: string;
|
|
195
|
+
type?: string;
|
|
196
|
+
}>;
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/** The `object` payload shape the metadata platform responds with. */
|
|
202
|
+
export interface PageObject {
|
|
203
|
+
type: "page";
|
|
204
|
+
language?: string;
|
|
205
|
+
title?: string;
|
|
206
|
+
name?: string;
|
|
207
|
+
description?: string;
|
|
208
|
+
image?: Array<{
|
|
209
|
+
url: string;
|
|
210
|
+
type?: string;
|
|
211
|
+
width?: string | number;
|
|
212
|
+
height?: string | number;
|
|
213
|
+
}>;
|
|
214
|
+
video?: {
|
|
215
|
+
url: string;
|
|
216
|
+
thumbnail?: string;
|
|
217
|
+
width?: number;
|
|
218
|
+
height?: number;
|
|
219
|
+
duration?: number;
|
|
220
|
+
};
|
|
221
|
+
url?: string;
|
|
222
|
+
favicon?: string;
|
|
223
|
+
charset?: string;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Map an FxTwitter API response onto the platform's page object. The
|
|
227
|
+
* preview image is the post's own media — first photo, else the video
|
|
228
|
+
* thumbnail — never the generic site banner. Returns null when the API
|
|
229
|
+
* reports anything but success (the caller falls back to the OG scrape).
|
|
230
|
+
*/
|
|
231
|
+
export declare function tweetToPageObject(status: FxTwitterStatus): PageObject | null;
|
package/dist/schema.d.ts
CHANGED
|
@@ -76,6 +76,28 @@ export declare const PlatformMetadataSchema: {
|
|
|
76
76
|
image: {
|
|
77
77
|
$ref: string;
|
|
78
78
|
};
|
|
79
|
+
video: {
|
|
80
|
+
type: string;
|
|
81
|
+
required: string[];
|
|
82
|
+
additionalProperties: boolean;
|
|
83
|
+
properties: {
|
|
84
|
+
url: {
|
|
85
|
+
type: string;
|
|
86
|
+
};
|
|
87
|
+
thumbnail: {
|
|
88
|
+
type: string;
|
|
89
|
+
};
|
|
90
|
+
width: {
|
|
91
|
+
type: string;
|
|
92
|
+
};
|
|
93
|
+
height: {
|
|
94
|
+
type: string;
|
|
95
|
+
};
|
|
96
|
+
duration: {
|
|
97
|
+
type: string;
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
};
|
|
79
101
|
url: {
|
|
80
102
|
type: string;
|
|
81
103
|
};
|
|
@@ -106,6 +128,9 @@ export declare const PlatformMetadataSchema: {
|
|
|
106
128
|
height: {
|
|
107
129
|
type: string[];
|
|
108
130
|
};
|
|
131
|
+
alt: {
|
|
132
|
+
type: string;
|
|
133
|
+
};
|
|
109
134
|
};
|
|
110
135
|
};
|
|
111
136
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sockethub/platform-metadata",
|
|
3
|
-
"version": "1.0.1-
|
|
3
|
+
"version": "1.0.1-beta.1",
|
|
4
4
|
"description": "Fetch metadata from a given URL",
|
|
5
5
|
"author": "Nick Jennings <nick@silverbucket.net>",
|
|
6
6
|
"license": "LGPL-3.0+",
|
|
@@ -42,15 +42,16 @@
|
|
|
42
42
|
"clean:deps": "rm -rf node_modules"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@sockethub/util": "^1.0.0-
|
|
46
|
-
"open-graph-scraper": "^6.11.0"
|
|
45
|
+
"@sockethub/util": "^1.0.0-beta.1",
|
|
46
|
+
"open-graph-scraper": "^6.11.0",
|
|
47
|
+
"undici": "^7.30.0"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
|
49
|
-
"@sockethub/schemas": "^3.0.0-
|
|
50
|
+
"@sockethub/schemas": "^3.0.0-beta.1"
|
|
50
51
|
},
|
|
51
52
|
"peerDependencies": {
|
|
52
|
-
"@sockethub/server": "^5.0.0-
|
|
53
|
+
"@sockethub/server": "^5.0.0-beta.1"
|
|
53
54
|
},
|
|
54
|
-
"gitHead": "
|
|
55
|
+
"gitHead": "928f0c480e4828f492fb29a97712474a8f1bcaef",
|
|
55
56
|
"types": "./dist/index.d.ts"
|
|
56
57
|
}
|