@sockethub/platform-metadata 1.0.1-beta.0 → 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 CHANGED
@@ -101,8 +101,12 @@ so those URLs are resolved through preview services built for this purpose:
101
101
  self-text, and direct `i.redd.it` image are mapped into the preview; text posts
102
102
  intentionally have no image. oEmbed remains a title-only failure fallback.
103
103
  See `compatUserAgent` below.
104
- * **Facebook** remains best-effort: post text usually comes through, but
105
- media requires authentication and no public preview service exists.
104
+ * **Facebook** remains best-effort: post text usually comes through. The
105
+ video or photo thumbnail is taken from the post's Open Graph image, then
106
+ its Twitter card image, and finally the image rendered by Facebook's public
107
+ embed plugin (`/plugins/video.php` or `/plugins/post.php`), which Facebook
108
+ serves to third parties when it withholds Open Graph media from a
109
+ datacenter address. Full media still requires authentication.
106
110
 
107
111
  ### `userAgent`
108
112
 
@@ -151,9 +155,14 @@ Because the server fetches whatever URL a client supplies as `actor.id`, metadat
151
155
  requests are hardened against server-side request forgery (SSRF) and resource
152
156
  exhaustion. The fetch runs through a guarded connection layer that:
153
157
 
158
+ * rejects URL schemes other than `http` and `https`;
154
159
  * refuses to connect to loopback, private, link-local, carrier-grade NAT, or
155
- cloud-metadata addresses (e.g. `169.254.169.254`), in every IPv4/IPv6 spelling,
156
- validated at connection time so it also covers redirect hops;
160
+ cloud-metadata addresses (e.g. `169.254.169.254`), validated at connection
161
+ time so it also covers redirect hops. IPv4-mapped, IPv4-compatible, and
162
+ well-known NAT64 (`64:ff9b::/96`) embeddings are judged as the embedded IPv4
163
+ address. The local-use NAT64 prefix `64:ff9b:1::/48` is refused entirely.
164
+ The ranges are listed under
165
+ [Outbound URL guard](../../docs/configuration.md#outbound-url-guard);
157
166
  * caps the response body size.
158
167
 
159
168
  ### `allowPrivateAddresses`
package/dist/index.d.ts CHANGED
@@ -168,6 +168,12 @@ export default class Metadata implements PlatformInterface {
168
168
  private fetchDirectImage;
169
169
  /** Fetch and validate YouTube's official preview metadata. */
170
170
  private fetchYouTubeEmbed;
171
+ /**
172
+ * Last-resort thumbnail for a Facebook post whose page carried no
173
+ * Open Graph image: the media image rendered by Facebook's public embed
174
+ * plugin, which is served to third parties without a login wall.
175
+ */
176
+ private fetchFacebookEmbedImage;
171
177
  private fetchTweet;
172
178
  private fetchReddit;
173
179
  /**
package/dist/index.js CHANGED
@@ -74222,7 +74222,7 @@ var import_undici3 = __toESM(require_undici(), 1);
74222
74222
  // package.json
74223
74223
  var package_default = {
74224
74224
  name: "@sockethub/platform-metadata",
74225
- version: "1.0.1-beta.0",
74225
+ version: "1.0.1-beta.1",
74226
74226
  description: "Fetch metadata from a given URL",
74227
74227
  author: "Nick Jennings <nick@silverbucket.net>",
74228
74228
  license: "LGPL-3.0+",
@@ -74264,17 +74264,17 @@ var package_default = {
74264
74264
  "clean:deps": "rm -rf node_modules"
74265
74265
  },
74266
74266
  dependencies: {
74267
- "@sockethub/util": "^1.0.0-beta.0",
74267
+ "@sockethub/util": "^1.0.0-beta.1",
74268
74268
  "open-graph-scraper": "^6.11.0",
74269
74269
  undici: "^7.30.0"
74270
74270
  },
74271
74271
  devDependencies: {
74272
- "@sockethub/schemas": "^3.0.0-beta.0"
74272
+ "@sockethub/schemas": "^3.0.0-beta.1"
74273
74273
  },
74274
74274
  peerDependencies: {
74275
- "@sockethub/server": "^5.0.0-beta.0"
74275
+ "@sockethub/server": "^5.0.0-beta.1"
74276
74276
  },
74277
- gitHead: "75002ae8f15d7679bd58a9327f5a1d7eeaffa237",
74277
+ gitHead: "06c9062f1cb5f0c0599fd979e6e91da79f08eda0",
74278
74278
  types: "./dist/index.d.ts"
74279
74279
  };
74280
74280
 
@@ -74447,7 +74447,35 @@ function isFacebookUrl(url) {
74447
74447
  return FACEBOOK_HOSTS.has(host) || host === "fb.watch";
74448
74448
  }
74449
74449
  function stripFacebookEngagement(title) {
74450
- return title?.replace(/^[^|]*\p{Nd}[^|]*·[^|]*\|\s*/u, "");
74450
+ const bar = title?.indexOf("|") ?? -1;
74451
+ if (title === undefined || bar === -1) {
74452
+ return title;
74453
+ }
74454
+ const segment = title.slice(0, bar);
74455
+ const dot = segment.lastIndexOf("·");
74456
+ const isStats = dot !== -1 && /\p{Nd}/u.test(segment.slice(0, dot)) || /^\s*(?:\p{Nd}+[.,]\p{Nd}+(?:[.,\s]\p{Nd}+)*\p{L}{0,2}|\p{Nd}{1,3}\p{L}{1,2}|\p{Nd}{4,})(?:\s+\p{L}+\.)?\s+\p{L}+\s*$/u.test(segment);
74457
+ return isStats ? title.slice(bar + 1).trimStart() : title;
74458
+ }
74459
+ function resolveFacebookEmbed(url, ogType) {
74460
+ if (!isFacebookUrl(url)) {
74461
+ return null;
74462
+ }
74463
+ const isVideo = ogType?.startsWith("video") || /^\/(?:reel|watch)(?:\/|$)|\/videos\//i.test(new URL(url).pathname);
74464
+ const plugin = isVideo ? "video.php" : "post.php";
74465
+ return `https://www.facebook.com/plugins/${plugin}?href=${encodeURIComponent(url)}`;
74466
+ }
74467
+ var FACEBOOK_EMBED_MAX_CHARS = 500000;
74468
+ function extractFacebookEmbedImage(html) {
74469
+ const match = html.slice(0, FACEBOOK_EMBED_MAX_CHARS).match(/<img\b[^>]*\bsrc="(https:\/\/[^"]*\.fbcdn\.net\/[^"]*)"/i);
74470
+ if (!match) {
74471
+ return;
74472
+ }
74473
+ const url = match[1].replace(/&amp;/g, "&");
74474
+ try {
74475
+ return new URL(url).hostname.endsWith(".fbcdn.net") ? url : undefined;
74476
+ } catch {
74477
+ return;
74478
+ }
74451
74479
  }
74452
74480
  function isRedditUrl(url) {
74453
74481
  let parsed;
@@ -74753,6 +74781,7 @@ var TWEET_API_TIMEOUT_MS = 1e4;
74753
74781
  var REDDIT_OEMBED_URL = "https://www.reddit.com/oembed";
74754
74782
  var REDDIT_OEMBED_TIMEOUT_MS = 4000;
74755
74783
  var YOUTUBE_OEMBED_TIMEOUT_MS = 4000;
74784
+ var FACEBOOK_EMBED_TIMEOUT_MS = 4000;
74756
74785
  var SCRAPE_TIMEOUT_MS = 5000;
74757
74786
  var REDDIT_JSON_TIMEOUT_MS = 2500;
74758
74787
  var REDDIT_JSON_MAX_BYTES = 1e6;
@@ -74939,6 +74968,29 @@ class Metadata {
74939
74968
  return;
74940
74969
  }
74941
74970
  }
74971
+ async fetchFacebookEmbedImage(embedUrl) {
74972
+ try {
74973
+ const res = await this.fetchImpl(embedUrl, {
74974
+ dispatcher: this.getDispatcher(),
74975
+ headers: { "user-agent": this.userAgent() },
74976
+ signal: AbortSignal.timeout(FACEBOOK_EMBED_TIMEOUT_MS)
74977
+ });
74978
+ if (!res.ok) {
74979
+ await res.body?.cancel().catch(() => {
74980
+ return;
74981
+ });
74982
+ throw new Error(`Facebook embed returned HTTP ${res.status}`);
74983
+ }
74984
+ const image = extractFacebookEmbedImage(await res.text());
74985
+ if (!image) {
74986
+ throw new Error("Facebook embed contained no CDN image");
74987
+ }
74988
+ return image;
74989
+ } catch (err) {
74990
+ this.log.debug(`facebook embed fetch failed for ${embedUrl}: ${String(err)}; using scraped metadata`);
74991
+ return;
74992
+ }
74993
+ }
74942
74994
  async fetchTweet(apiUrl, job, cb) {
74943
74995
  try {
74944
74996
  const res = await this.fetchImpl(apiUrl, {
@@ -75158,6 +75210,22 @@ class Metadata {
75158
75210
  this.log.debug(`facebook canonical scrape failed for ${result.ogUrl}: ${String(err)}; using share metadata`);
75159
75211
  }
75160
75212
  }
75213
+ if (facebook && !result.ogImage?.length) {
75214
+ if (result.twitterImage?.length) {
75215
+ result = { ...result, ogImage: result.twitterImage };
75216
+ } else {
75217
+ const embedUrl = resolveFacebookEmbed(result.ogUrl || job.actor.id, result.ogType);
75218
+ const embedImage = embedUrl ? await withDeadline(this.fetchFacebookEmbedImage(embedUrl), FACEBOOK_EMBED_TIMEOUT_MS).catch(() => {
75219
+ return;
75220
+ }) : undefined;
75221
+ if (embedImage) {
75222
+ result = {
75223
+ ...result,
75224
+ ogImage: [{ url: embedImage }]
75225
+ };
75226
+ }
75227
+ }
75228
+ }
75161
75229
  const embed = reddit ? await redditEmbed : undefined;
75162
75230
  const youtube = await youtubeEmbed;
75163
75231
  if (!reddit)
@@ -75175,7 +75243,7 @@ class Metadata {
75175
75243
  name: embed?.provider_name ?? youtube?.provider_name ?? siteName,
75176
75244
  description: normalizeDescription(youtubeDescription || result.ogDescription || ""),
75177
75245
  image: reddit ? redditPostImages(result.ogImage) ?? redditOEmbedImage(embed ?? {}) : scrapedImages?.length ? scrapedImages : youtubeOEmbedImage(youtube),
75178
- url: reddit ? job.actor.id : result.ogUrl,
75246
+ url: job.actor.id,
75179
75247
  favicon: result.favicon || "/favicon.ico",
75180
75248
  charset: result.charset
75181
75249
  };
@@ -75233,4 +75301,4 @@ export {
75233
75301
  withDeadline
75234
75302
  };
75235
75303
 
75236
- //# debugId=A3842767FBDDF58564756E2164756E21
75304
+ //# debugId=E636DFFA5AC4B45D64756E2164756E21