@sockethub/platform-metadata 1.0.1-alpha.17 → 1.0.1-alpha.19

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/dist/index.d.ts CHANGED
@@ -160,6 +160,8 @@ export default class Metadata implements PlatformInterface {
160
160
  private userAgent;
161
161
  private compatUserAgent;
162
162
  fetch(job: ActivityStream, cb: PlatformCallback): void;
163
+ /** Fetch and validate YouTube's official preview metadata. */
164
+ private fetchYouTubeEmbed;
163
165
  private fetchTweet;
164
166
  private fetchReddit;
165
167
  /**
package/dist/index.js CHANGED
@@ -71661,6 +71661,11 @@ function errorMessage(value) {
71661
71661
  }
71662
71662
  return String(value);
71663
71663
  }
71664
+ var EXPECTED_ERROR = Symbol.for("sockethub.expectedError");
71665
+ function markExpectedError(err) {
71666
+ err[EXPECTED_ERROR] = true;
71667
+ return err;
71668
+ }
71664
71669
 
71665
71670
  // ../util/dist/net/index.js
71666
71671
  var import_undici = __toESM(require_undici(), 1);
@@ -71813,7 +71818,7 @@ var import_undici2 = __toESM(require_undici(), 1);
71813
71818
  // package.json
71814
71819
  var package_default = {
71815
71820
  name: "@sockethub/platform-metadata",
71816
- version: "1.0.1-alpha.17",
71821
+ version: "1.0.1-alpha.19",
71817
71822
  description: "Fetch metadata from a given URL",
71818
71823
  author: "Nick Jennings <nick@silverbucket.net>",
71819
71824
  license: "LGPL-3.0+",
@@ -71855,17 +71860,17 @@ var package_default = {
71855
71860
  "clean:deps": "rm -rf node_modules"
71856
71861
  },
71857
71862
  dependencies: {
71858
- "@sockethub/util": "^1.0.0-alpha.10",
71863
+ "@sockethub/util": "^1.0.0-alpha.12",
71859
71864
  "open-graph-scraper": "^6.11.0",
71860
71865
  undici: "^7.16.0"
71861
71866
  },
71862
71867
  devDependencies: {
71863
- "@sockethub/schemas": "^3.0.0-alpha.22"
71868
+ "@sockethub/schemas": "^3.0.0-alpha.24"
71864
71869
  },
71865
71870
  peerDependencies: {
71866
- "@sockethub/server": "^5.0.0-alpha.22"
71871
+ "@sockethub/server": "^5.0.0-alpha.24"
71867
71872
  },
71868
- gitHead: "1630010f605e85cc548e3323cb9694fbe3a7a18a",
71873
+ gitHead: "e778256d7067e2743cd6807a15d39a0cc65a358d",
71869
71874
  types: "./dist/index.d.ts"
71870
71875
  };
71871
71876
 
@@ -71878,6 +71883,21 @@ var TWITTER_HOSTS = new Set([
71878
71883
  "www.x.com",
71879
71884
  "mobile.x.com"
71880
71885
  ]);
71886
+ var YOUTUBE_HOSTS = new Set([
71887
+ "youtube.com",
71888
+ "www.youtube.com",
71889
+ "m.youtube.com",
71890
+ "music.youtube.com"
71891
+ ]);
71892
+ var FACEBOOK_HOSTS = new Set([
71893
+ "facebook.com",
71894
+ "www.facebook.com",
71895
+ "m.facebook.com",
71896
+ "web.facebook.com",
71897
+ "mbasic.facebook.com",
71898
+ "fb.com",
71899
+ "www.fb.com"
71900
+ ]);
71881
71901
  var REDDIT_HOSTS = new Set([
71882
71902
  "reddit.com",
71883
71903
  "www.reddit.com",
@@ -71901,6 +71921,79 @@ function resolveTwitterStatus(url) {
71901
71921
  }
71902
71922
  return `https://api.fxtwitter.com/status/${match[1]}`;
71903
71923
  }
71924
+ function resolveYouTubeOEmbed(url) {
71925
+ let parsed;
71926
+ try {
71927
+ parsed = new URL(url);
71928
+ } catch {
71929
+ return null;
71930
+ }
71931
+ const host = parsed.hostname.toLowerCase();
71932
+ let videoId;
71933
+ if (YOUTUBE_HOSTS.has(host)) {
71934
+ if (parsed.pathname === "/watch") {
71935
+ videoId = parsed.searchParams.get("v");
71936
+ } else {
71937
+ videoId = parsed.pathname.match(/^\/(?:shorts|live|embed)\/([A-Za-z0-9_-]{11})(?:\/|$)/)?.[1];
71938
+ }
71939
+ } else if (host === "youtu.be") {
71940
+ videoId = parsed.pathname.match(/^\/([A-Za-z0-9_-]{11})(?:\/|$)/)?.[1];
71941
+ }
71942
+ if (!videoId || !/^[A-Za-z0-9_-]{11}$/.test(videoId))
71943
+ return null;
71944
+ const canonical = new URL("https://www.youtube.com/watch");
71945
+ canonical.searchParams.set("v", videoId);
71946
+ const endpoint = new URL("https://www.youtube.com/oembed");
71947
+ endpoint.searchParams.set("url", canonical.href);
71948
+ endpoint.searchParams.set("format", "json");
71949
+ return endpoint.href;
71950
+ }
71951
+ function parseYouTubeOEmbed(value) {
71952
+ if (!value || typeof value !== "object" || Array.isArray(value))
71953
+ return null;
71954
+ const embed = value;
71955
+ if (typeof embed.title !== "string" || !embed.title || typeof embed.thumbnail_url !== "string" || !embed.thumbnail_url) {
71956
+ return null;
71957
+ }
71958
+ if (embed.provider_name !== undefined && typeof embed.provider_name !== "string") {
71959
+ return null;
71960
+ }
71961
+ for (const key of ["thumbnail_width", "thumbnail_height"]) {
71962
+ if (embed[key] !== undefined && (typeof embed[key] !== "number" || !Number.isFinite(embed[key]) || embed[key] < 0)) {
71963
+ return null;
71964
+ }
71965
+ }
71966
+ try {
71967
+ const thumbnail = new URL(embed.thumbnail_url);
71968
+ if (!["http:", "https:"].includes(thumbnail.protocol))
71969
+ return null;
71970
+ } catch {
71971
+ return null;
71972
+ }
71973
+ return embed;
71974
+ }
71975
+ function youtubeOEmbedImage(embed) {
71976
+ return embed ? [
71977
+ {
71978
+ url: embed.thumbnail_url,
71979
+ width: embed.thumbnail_width,
71980
+ height: embed.thumbnail_height
71981
+ }
71982
+ ] : undefined;
71983
+ }
71984
+ function isFacebookUrl(url) {
71985
+ let parsed;
71986
+ try {
71987
+ parsed = new URL(url);
71988
+ } catch {
71989
+ return false;
71990
+ }
71991
+ const host = parsed.hostname.toLowerCase();
71992
+ return FACEBOOK_HOSTS.has(host) || host === "fb.watch";
71993
+ }
71994
+ function stripFacebookEngagement(title) {
71995
+ return title?.replace(/^[^|]*\p{Nd}[^|]*·[^|]*\|\s*/u, "");
71996
+ }
71904
71997
  function isRedditUrl(url) {
71905
71998
  let parsed;
71906
71999
  try {
@@ -71927,6 +72020,19 @@ function resolveRedditJson(url) {
71927
72020
  json.searchParams.set("raw_json", "1");
71928
72021
  return json.href;
71929
72022
  }
72023
+ function resolveRedditMedia(url) {
72024
+ let parsed;
72025
+ try {
72026
+ parsed = new URL(url);
72027
+ } catch {
72028
+ return null;
72029
+ }
72030
+ if (!REDDIT_HOSTS.has(parsed.hostname.toLowerCase()))
72031
+ return null;
72032
+ if (parsed.pathname.replace(/\/+$/, "") !== "/media")
72033
+ return null;
72034
+ return redditMediaUrl(parsed.searchParams.get("url"), new Set(["i.redd.it", "preview.redd.it", "external-preview.redd.it"]));
72035
+ }
71930
72036
  function parseRedditPost(value) {
71931
72037
  if (!Array.isArray(value))
71932
72038
  return null;
@@ -71942,19 +72048,51 @@ function parseRedditPost(value) {
71942
72048
  }
71943
72049
  return data;
71944
72050
  }
72051
+ function redditMediaUrl(value, allowedHosts) {
72052
+ if (typeof value !== "string")
72053
+ return null;
72054
+ try {
72055
+ const url = new URL(value);
72056
+ return url.protocol === "https:" && allowedHosts.has(url.hostname.toLowerCase()) ? url.href : null;
72057
+ } catch {
72058
+ return null;
72059
+ }
72060
+ }
72061
+ function finiteNonNegative(value) {
72062
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
72063
+ }
71945
72064
  function redditPostImage(post) {
71946
72065
  for (const candidate of [post.url_overridden_by_dest, post.url]) {
71947
- if (!candidate)
71948
- continue;
71949
- try {
71950
- const url = new URL(candidate);
71951
- if (["i.redd.it", "preview.redd.it"].includes(url.hostname.toLowerCase())) {
71952
- return [{ url: url.href }];
72066
+ const url = redditMediaUrl(candidate, new Set(["i.redd.it", "preview.redd.it"]));
72067
+ if (url)
72068
+ return [{ url }];
72069
+ }
72070
+ const source = post.preview?.images?.[0]?.source;
72071
+ const previewUrl = redditMediaUrl(source?.url, new Set(["preview.redd.it", "external-preview.redd.it"]));
72072
+ if (previewUrl) {
72073
+ return [
72074
+ {
72075
+ url: previewUrl,
72076
+ width: finiteNonNegative(source?.width),
72077
+ height: finiteNonNegative(source?.height)
71953
72078
  }
71954
- } catch {}
72079
+ ];
71955
72080
  }
71956
72081
  return;
71957
72082
  }
72083
+ function redditPostVideo(post) {
72084
+ const video = post.secure_media?.reddit_video;
72085
+ const url = redditMediaUrl(video?.fallback_url, new Set(["v.redd.it"]));
72086
+ if (!url)
72087
+ return;
72088
+ return {
72089
+ url,
72090
+ thumbnail: redditPostImage(post)?.[0]?.url,
72091
+ width: finiteNonNegative(video?.width),
72092
+ height: finiteNonNegative(video?.height),
72093
+ duration: finiteNonNegative(video?.duration)
72094
+ };
72095
+ }
71958
72096
  function parseRedditOEmbed(value) {
71959
72097
  if (!value || typeof value !== "object" || Array.isArray(value))
71960
72098
  return null;
@@ -72026,15 +72164,21 @@ function tweetToPageObject(status) {
72026
72164
  return null;
72027
72165
  }
72028
72166
  const author = tweet.author ?? {};
72029
- const title = author.name && author.screen_name ? `${author.name} (@${author.screen_name}) on X` : author.name ?? "Post on X";
72167
+ const article = tweet.article;
72168
+ const articleBlocks = article?.content?.blocks;
72169
+ const articleBody = Array.isArray(articleBlocks) ? articleBlocks.map((block) => block?.type !== "atomic" && typeof block?.text === "string" ? block.text.trim() : "").filter(Boolean).join(`
72170
+
72171
+ `) : undefined;
72172
+ const title = typeof article?.title === "string" && article.title || (author.name && author.screen_name ? `${author.name} (@${author.screen_name}) on X` : author.name ?? "Post on X");
72030
72173
  const photo = tweet.media?.photos?.[0];
72031
72174
  const video = tweet.media?.videos?.[0];
72032
- const imageUrl = photo?.url ?? video?.thumbnail_url;
72175
+ const articleImage = article?.cover_media?.media_info;
72176
+ const imageUrl = articleImage?.original_img_url ?? photo?.url ?? video?.thumbnail_url;
72033
72177
  const page = {
72034
72178
  type: "page",
72035
72179
  title,
72036
72180
  name: "X (formerly Twitter)",
72037
- description: normalizeDescription(tweet.text ?? ""),
72181
+ description: normalizeDescription(articleBody || (typeof article?.preview_text === "string" && article.preview_text.trim() ? article.preview_text : "") || tweet.text || ""),
72038
72182
  url: tweet.url,
72039
72183
  favicon: "https://x.com/favicon.ico"
72040
72184
  };
@@ -72042,8 +72186,8 @@ function tweetToPageObject(status) {
72042
72186
  page.image = [
72043
72187
  {
72044
72188
  url: imageUrl,
72045
- width: photo?.width ?? video?.width,
72046
- height: photo?.height ?? video?.height
72189
+ width: articleImage?.original_img_width ?? photo?.width ?? video?.width,
72190
+ height: articleImage?.original_img_height ?? photo?.height ?? video?.height
72047
72191
  }
72048
72192
  ];
72049
72193
  }
@@ -72153,6 +72297,7 @@ var COMPAT_USER_AGENT = "Mozilla/5.0 (compatible; Discordbot/2.0; +https://disco
72153
72297
  var TWEET_API_TIMEOUT_MS = 1e4;
72154
72298
  var REDDIT_OEMBED_URL = "https://www.reddit.com/oembed";
72155
72299
  var REDDIT_OEMBED_TIMEOUT_MS = 4000;
72300
+ var YOUTUBE_OEMBED_TIMEOUT_MS = 4000;
72156
72301
  var SCRAPE_TIMEOUT_MS = 5000;
72157
72302
  var REDDIT_JSON_TIMEOUT_MS = 2500;
72158
72303
  var REDDIT_JSON_MAX_BYTES = 1e6;
@@ -72210,8 +72355,36 @@ class Metadata {
72210
72355
  this.fetchReddit(job, cb);
72211
72356
  return;
72212
72357
  }
72358
+ const youtubeOEmbedUrl = resolveYouTubeOEmbed(job.actor.id);
72359
+ if (youtubeOEmbedUrl) {
72360
+ const embed = withDeadline(this.fetchYouTubeEmbed(youtubeOEmbedUrl), YOUTUBE_OEMBED_TIMEOUT_MS).catch(() => {
72361
+ return;
72362
+ });
72363
+ this.scrape(job, cb, undefined, job.actor.id, embed);
72364
+ return;
72365
+ }
72213
72366
  this.scrape(job, cb);
72214
72367
  }
72368
+ async fetchYouTubeEmbed(embedUrl) {
72369
+ try {
72370
+ const res = await this.fetchImpl(embedUrl, {
72371
+ dispatcher: this.getDispatcher(),
72372
+ headers: { "user-agent": this.userAgent() },
72373
+ signal: AbortSignal.timeout(YOUTUBE_OEMBED_TIMEOUT_MS)
72374
+ });
72375
+ if (!res.ok) {
72376
+ throw new Error(`YouTube oEmbed returned HTTP ${res.status}`);
72377
+ }
72378
+ const embed = parseYouTubeOEmbed(await res.json());
72379
+ if (!embed) {
72380
+ throw new Error("YouTube oEmbed returned an invalid payload");
72381
+ }
72382
+ return embed;
72383
+ } catch (err) {
72384
+ this.log.debug(`youtube oEmbed fetch failed for ${embedUrl}: ${String(err)}; using scraped metadata`);
72385
+ return;
72386
+ }
72387
+ }
72215
72388
  async fetchTweet(apiUrl, job, cb) {
72216
72389
  try {
72217
72390
  const res = await this.fetchImpl(apiUrl, {
@@ -72235,6 +72408,29 @@ class Metadata {
72235
72408
  this.scrape(job, cb);
72236
72409
  }
72237
72410
  async fetchReddit(job, cb) {
72411
+ const mediaImage = resolveRedditMedia(job.actor.id);
72412
+ if (mediaImage) {
72413
+ const imagePath = new URL(mediaImage).pathname;
72414
+ let title;
72415
+ try {
72416
+ title = decodeURIComponent(imagePath).slice(1);
72417
+ } catch {
72418
+ title = imagePath.slice(1);
72419
+ }
72420
+ job.actor.name = "reddit";
72421
+ job.object = {
72422
+ type: "page",
72423
+ title,
72424
+ name: "reddit",
72425
+ description: "",
72426
+ image: [{ url: mediaImage }],
72427
+ url: job.actor.id,
72428
+ favicon: "/favicon.ico"
72429
+ };
72430
+ this.log.debug(`reddit media link unwrapped for ${job.actor.id}`);
72431
+ cb(null, job);
72432
+ return;
72433
+ }
72238
72434
  const jsonUrl = resolveRedditJson(job.actor.id);
72239
72435
  const embedPromise = withDeadline(this.fetchRedditEmbed(job.actor.id), REDDIT_JSON_TIMEOUT_MS).catch(() => {
72240
72436
  return;
@@ -72252,6 +72448,7 @@ class Metadata {
72252
72448
  name: "reddit",
72253
72449
  description: normalizeDescription(post.selftext ?? ""),
72254
72450
  image: redditPostImage(post),
72451
+ video: redditPostVideo(post),
72255
72452
  url: job.actor.id,
72256
72453
  favicon: "/favicon.ico"
72257
72454
  };
@@ -72277,7 +72474,7 @@ class Metadata {
72277
72474
  cb(null, job);
72278
72475
  return;
72279
72476
  }
72280
- cb(new Error(`No Reddit metadata available for ${job.actor.id}`));
72477
+ cb(markExpectedError(new Error(`No Reddit metadata available for ${job.actor.id}`)));
72281
72478
  }
72282
72479
  fetchRedditJson(url) {
72283
72480
  return new Promise((resolve, reject) => {
@@ -72339,8 +72536,9 @@ class Metadata {
72339
72536
  return;
72340
72537
  }
72341
72538
  }
72342
- scrape(job, cb, redditEmbed, scrapeUrl = job.actor.id) {
72343
- const userAgent = isRedditUrl(job.actor.id) ? this.compatUserAgent() : this.userAgent();
72539
+ scrape(job, cb, redditEmbed, scrapeUrl = job.actor.id, youtubeEmbed) {
72540
+ const useCompatUserAgent = isRedditUrl(job.actor.id) || isFacebookUrl(job.actor.id) || Boolean(resolveYouTubeOEmbed(job.actor.id));
72541
+ const userAgent = useCompatUserAgent ? this.compatUserAgent() : this.userAgent();
72344
72542
  const dispatcher = this.getDispatcher();
72345
72543
  this.log.debug(`scrape started for ${job.actor.id} via ${scrapeUrl}`);
72346
72544
  const options = {
@@ -72379,17 +72577,24 @@ class Metadata {
72379
72577
  const { result } = data;
72380
72578
  this.log.debug(`scrape completed for ${job.actor.id}`);
72381
72579
  const reddit = isRedditUrl(job.actor.id);
72580
+ const facebook = isFacebookUrl(job.actor.id);
72382
72581
  const embed = reddit ? await redditEmbed : undefined;
72582
+ const youtube = await youtubeEmbed;
72383
72583
  if (!reddit)
72384
72584
  job.actor.id = result.ogUrl || job.actor.id;
72385
- job.actor.name = reddit ? embed?.provider_name ?? "reddit" : result.ogSiteName ?? job.actor.name ?? "";
72585
+ const siteName = result.ogSiteName ?? (facebook ? "Facebook" : undefined);
72586
+ const scrapedImages = facebook ? result.ogImage?.map((image) => ({
72587
+ ...image,
72588
+ alt: stripFacebookEngagement(image.alt)
72589
+ })) : result.ogImage;
72590
+ job.actor.name = reddit ? embed?.provider_name ?? "reddit" : siteName ?? job.actor.name ?? "";
72386
72591
  job.object = {
72387
72592
  type: "page",
72388
72593
  language: result.ogLocale,
72389
- title: embed?.title ?? result.ogTitle,
72390
- name: embed?.provider_name ?? result.ogSiteName,
72594
+ title: embed?.title ?? youtube?.title ?? (facebook ? stripFacebookEngagement(result.ogTitle) : result.ogTitle),
72595
+ name: embed?.provider_name ?? youtube?.provider_name ?? siteName,
72391
72596
  description: normalizeDescription(result.ogDescription || ""),
72392
- image: reddit ? redditPostImages(result.ogImage) ?? redditOEmbedImage(embed ?? {}) : result.ogImage,
72597
+ image: reddit ? redditPostImages(result.ogImage) ?? redditOEmbedImage(embed ?? {}) : scrapedImages?.length ? scrapedImages : youtubeOEmbedImage(youtube),
72393
72598
  url: reddit ? job.actor.id : result.ogUrl,
72394
72599
  favicon: result.favicon || "/favicon.ico",
72395
72600
  charset: result.charset
@@ -72416,7 +72621,23 @@ class Metadata {
72416
72621
  return;
72417
72622
  }
72418
72623
  }
72419
- cb(err);
72624
+ const youtube = await youtubeEmbed;
72625
+ if (youtube) {
72626
+ job.actor.name = youtube.provider_name || "YouTube";
72627
+ job.object = {
72628
+ type: "page",
72629
+ title: youtube.title,
72630
+ name: youtube.provider_name || "YouTube",
72631
+ description: "",
72632
+ image: youtubeOEmbedImage(youtube),
72633
+ url: job.actor.id,
72634
+ favicon: "/favicon.ico"
72635
+ };
72636
+ this.log.debug(`using youtube oEmbed fallback for ${job.actor.id}`);
72637
+ cb(null, job);
72638
+ return;
72639
+ }
72640
+ cb(markExpectedError(new Error(`metadata scrape failed for ${job.actor.id}: ${err.message}`)));
72420
72641
  });
72421
72642
  }
72422
72643
  cleanup(cb) {
@@ -72432,4 +72653,4 @@ export {
72432
72653
  withDeadline
72433
72654
  };
72434
72655
 
72435
- //# debugId=D50095C23221680664756E2164756E21
72656
+ //# debugId=136FC9DCA9AFCE9364756E2164756E21