@sockethub/platform-metadata 1.0.1-alpha.16 → 1.0.1-alpha.18

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
@@ -71813,7 +71813,7 @@ var import_undici2 = __toESM(require_undici(), 1);
71813
71813
  // package.json
71814
71814
  var package_default = {
71815
71815
  name: "@sockethub/platform-metadata",
71816
- version: "1.0.1-alpha.16",
71816
+ version: "1.0.1-alpha.18",
71817
71817
  description: "Fetch metadata from a given URL",
71818
71818
  author: "Nick Jennings <nick@silverbucket.net>",
71819
71819
  license: "LGPL-3.0+",
@@ -71855,17 +71855,17 @@ var package_default = {
71855
71855
  "clean:deps": "rm -rf node_modules"
71856
71856
  },
71857
71857
  dependencies: {
71858
- "@sockethub/util": "^1.0.0-alpha.9",
71858
+ "@sockethub/util": "^1.0.0-alpha.11",
71859
71859
  "open-graph-scraper": "^6.11.0",
71860
71860
  undici: "^7.16.0"
71861
71861
  },
71862
71862
  devDependencies: {
71863
- "@sockethub/schemas": "^3.0.0-alpha.21"
71863
+ "@sockethub/schemas": "^3.0.0-alpha.23"
71864
71864
  },
71865
71865
  peerDependencies: {
71866
- "@sockethub/server": "^5.0.0-alpha.21"
71866
+ "@sockethub/server": "^5.0.0-alpha.23"
71867
71867
  },
71868
- gitHead: "683f7b2c0cda2eda05c845eb98827cf13d055eb4",
71868
+ gitHead: "a6d303097640e82c7d1a7cef6dea2e6dae5f2cac",
71869
71869
  types: "./dist/index.d.ts"
71870
71870
  };
71871
71871
 
@@ -71878,6 +71878,12 @@ var TWITTER_HOSTS = new Set([
71878
71878
  "www.x.com",
71879
71879
  "mobile.x.com"
71880
71880
  ]);
71881
+ var YOUTUBE_HOSTS = new Set([
71882
+ "youtube.com",
71883
+ "www.youtube.com",
71884
+ "m.youtube.com",
71885
+ "music.youtube.com"
71886
+ ]);
71881
71887
  var REDDIT_HOSTS = new Set([
71882
71888
  "reddit.com",
71883
71889
  "www.reddit.com",
@@ -71901,6 +71907,66 @@ function resolveTwitterStatus(url) {
71901
71907
  }
71902
71908
  return `https://api.fxtwitter.com/status/${match[1]}`;
71903
71909
  }
71910
+ function resolveYouTubeOEmbed(url) {
71911
+ let parsed;
71912
+ try {
71913
+ parsed = new URL(url);
71914
+ } catch {
71915
+ return null;
71916
+ }
71917
+ const host = parsed.hostname.toLowerCase();
71918
+ let videoId;
71919
+ if (YOUTUBE_HOSTS.has(host)) {
71920
+ if (parsed.pathname === "/watch") {
71921
+ videoId = parsed.searchParams.get("v");
71922
+ } else {
71923
+ videoId = parsed.pathname.match(/^\/(?:shorts|live|embed)\/([A-Za-z0-9_-]{11})(?:\/|$)/)?.[1];
71924
+ }
71925
+ } else if (host === "youtu.be") {
71926
+ videoId = parsed.pathname.match(/^\/([A-Za-z0-9_-]{11})(?:\/|$)/)?.[1];
71927
+ }
71928
+ if (!videoId || !/^[A-Za-z0-9_-]{11}$/.test(videoId))
71929
+ return null;
71930
+ const canonical = new URL("https://www.youtube.com/watch");
71931
+ canonical.searchParams.set("v", videoId);
71932
+ const endpoint = new URL("https://www.youtube.com/oembed");
71933
+ endpoint.searchParams.set("url", canonical.href);
71934
+ endpoint.searchParams.set("format", "json");
71935
+ return endpoint.href;
71936
+ }
71937
+ function parseYouTubeOEmbed(value) {
71938
+ if (!value || typeof value !== "object" || Array.isArray(value))
71939
+ return null;
71940
+ const embed = value;
71941
+ if (typeof embed.title !== "string" || !embed.title || typeof embed.thumbnail_url !== "string" || !embed.thumbnail_url) {
71942
+ return null;
71943
+ }
71944
+ if (embed.provider_name !== undefined && typeof embed.provider_name !== "string") {
71945
+ return null;
71946
+ }
71947
+ for (const key of ["thumbnail_width", "thumbnail_height"]) {
71948
+ if (embed[key] !== undefined && (typeof embed[key] !== "number" || !Number.isFinite(embed[key]) || embed[key] < 0)) {
71949
+ return null;
71950
+ }
71951
+ }
71952
+ try {
71953
+ const thumbnail = new URL(embed.thumbnail_url);
71954
+ if (!["http:", "https:"].includes(thumbnail.protocol))
71955
+ return null;
71956
+ } catch {
71957
+ return null;
71958
+ }
71959
+ return embed;
71960
+ }
71961
+ function youtubeOEmbedImage(embed) {
71962
+ return embed ? [
71963
+ {
71964
+ url: embed.thumbnail_url,
71965
+ width: embed.thumbnail_width,
71966
+ height: embed.thumbnail_height
71967
+ }
71968
+ ] : undefined;
71969
+ }
71904
71970
  function isRedditUrl(url) {
71905
71971
  let parsed;
71906
71972
  try {
@@ -72026,15 +72092,21 @@ function tweetToPageObject(status) {
72026
72092
  return null;
72027
72093
  }
72028
72094
  const author = tweet.author ?? {};
72029
- const title = author.name && author.screen_name ? `${author.name} (@${author.screen_name}) on X` : author.name ?? "Post on X";
72095
+ const article = tweet.article;
72096
+ const articleBlocks = article?.content?.blocks;
72097
+ const articleBody = Array.isArray(articleBlocks) ? articleBlocks.map((block) => block?.type !== "atomic" && typeof block?.text === "string" ? block.text.trim() : "").filter(Boolean).join(`
72098
+
72099
+ `) : undefined;
72100
+ 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
72101
  const photo = tweet.media?.photos?.[0];
72031
72102
  const video = tweet.media?.videos?.[0];
72032
- const imageUrl = photo?.url ?? video?.thumbnail_url;
72103
+ const articleImage = article?.cover_media?.media_info;
72104
+ const imageUrl = articleImage?.original_img_url ?? photo?.url ?? video?.thumbnail_url;
72033
72105
  const page = {
72034
72106
  type: "page",
72035
72107
  title,
72036
72108
  name: "X (formerly Twitter)",
72037
- description: normalizeDescription(tweet.text ?? ""),
72109
+ description: normalizeDescription(articleBody || (typeof article?.preview_text === "string" && article.preview_text.trim() ? article.preview_text : "") || tweet.text || ""),
72038
72110
  url: tweet.url,
72039
72111
  favicon: "https://x.com/favicon.ico"
72040
72112
  };
@@ -72042,8 +72114,8 @@ function tweetToPageObject(status) {
72042
72114
  page.image = [
72043
72115
  {
72044
72116
  url: imageUrl,
72045
- width: photo?.width ?? video?.width,
72046
- height: photo?.height ?? video?.height
72117
+ width: articleImage?.original_img_width ?? photo?.width ?? video?.width,
72118
+ height: articleImage?.original_img_height ?? photo?.height ?? video?.height
72047
72119
  }
72048
72120
  ];
72049
72121
  }
@@ -72153,6 +72225,7 @@ var COMPAT_USER_AGENT = "Mozilla/5.0 (compatible; Discordbot/2.0; +https://disco
72153
72225
  var TWEET_API_TIMEOUT_MS = 1e4;
72154
72226
  var REDDIT_OEMBED_URL = "https://www.reddit.com/oembed";
72155
72227
  var REDDIT_OEMBED_TIMEOUT_MS = 4000;
72228
+ var YOUTUBE_OEMBED_TIMEOUT_MS = 4000;
72156
72229
  var SCRAPE_TIMEOUT_MS = 5000;
72157
72230
  var REDDIT_JSON_TIMEOUT_MS = 2500;
72158
72231
  var REDDIT_JSON_MAX_BYTES = 1e6;
@@ -72210,8 +72283,36 @@ class Metadata {
72210
72283
  this.fetchReddit(job, cb);
72211
72284
  return;
72212
72285
  }
72286
+ const youtubeOEmbedUrl = resolveYouTubeOEmbed(job.actor.id);
72287
+ if (youtubeOEmbedUrl) {
72288
+ const embed = withDeadline(this.fetchYouTubeEmbed(youtubeOEmbedUrl), YOUTUBE_OEMBED_TIMEOUT_MS).catch(() => {
72289
+ return;
72290
+ });
72291
+ this.scrape(job, cb, undefined, job.actor.id, embed);
72292
+ return;
72293
+ }
72213
72294
  this.scrape(job, cb);
72214
72295
  }
72296
+ async fetchYouTubeEmbed(embedUrl) {
72297
+ try {
72298
+ const res = await this.fetchImpl(embedUrl, {
72299
+ dispatcher: this.getDispatcher(),
72300
+ headers: { "user-agent": this.userAgent() },
72301
+ signal: AbortSignal.timeout(YOUTUBE_OEMBED_TIMEOUT_MS)
72302
+ });
72303
+ if (!res.ok) {
72304
+ throw new Error(`YouTube oEmbed returned HTTP ${res.status}`);
72305
+ }
72306
+ const embed = parseYouTubeOEmbed(await res.json());
72307
+ if (!embed) {
72308
+ throw new Error("YouTube oEmbed returned an invalid payload");
72309
+ }
72310
+ return embed;
72311
+ } catch (err) {
72312
+ this.log.debug(`youtube oEmbed fetch failed for ${embedUrl}: ${String(err)}; using scraped metadata`);
72313
+ return;
72314
+ }
72315
+ }
72215
72316
  async fetchTweet(apiUrl, job, cb) {
72216
72317
  try {
72217
72318
  const res = await this.fetchImpl(apiUrl, {
@@ -72339,8 +72440,9 @@ class Metadata {
72339
72440
  return;
72340
72441
  }
72341
72442
  }
72342
- scrape(job, cb, redditEmbed, scrapeUrl = job.actor.id) {
72343
- const userAgent = isRedditUrl(job.actor.id) ? this.compatUserAgent() : this.userAgent();
72443
+ scrape(job, cb, redditEmbed, scrapeUrl = job.actor.id, youtubeEmbed) {
72444
+ const useCompatUserAgent = isRedditUrl(job.actor.id) || Boolean(resolveYouTubeOEmbed(job.actor.id));
72445
+ const userAgent = useCompatUserAgent ? this.compatUserAgent() : this.userAgent();
72344
72446
  const dispatcher = this.getDispatcher();
72345
72447
  this.log.debug(`scrape started for ${job.actor.id} via ${scrapeUrl}`);
72346
72448
  const options = {
@@ -72380,16 +72482,17 @@ class Metadata {
72380
72482
  this.log.debug(`scrape completed for ${job.actor.id}`);
72381
72483
  const reddit = isRedditUrl(job.actor.id);
72382
72484
  const embed = reddit ? await redditEmbed : undefined;
72485
+ const youtube = await youtubeEmbed;
72383
72486
  if (!reddit)
72384
72487
  job.actor.id = result.ogUrl || job.actor.id;
72385
72488
  job.actor.name = reddit ? embed?.provider_name ?? "reddit" : result.ogSiteName ?? job.actor.name ?? "";
72386
72489
  job.object = {
72387
72490
  type: "page",
72388
72491
  language: result.ogLocale,
72389
- title: embed?.title ?? result.ogTitle,
72390
- name: embed?.provider_name ?? result.ogSiteName,
72492
+ title: embed?.title ?? youtube?.title ?? result.ogTitle,
72493
+ name: embed?.provider_name ?? youtube?.provider_name ?? result.ogSiteName,
72391
72494
  description: normalizeDescription(result.ogDescription || ""),
72392
- image: reddit ? redditPostImages(result.ogImage) ?? redditOEmbedImage(embed ?? {}) : result.ogImage,
72495
+ image: reddit ? redditPostImages(result.ogImage) ?? redditOEmbedImage(embed ?? {}) : result.ogImage?.length ? result.ogImage : youtubeOEmbedImage(youtube),
72393
72496
  url: reddit ? job.actor.id : result.ogUrl,
72394
72497
  favicon: result.favicon || "/favicon.ico",
72395
72498
  charset: result.charset
@@ -72416,6 +72519,22 @@ class Metadata {
72416
72519
  return;
72417
72520
  }
72418
72521
  }
72522
+ const youtube = await youtubeEmbed;
72523
+ if (youtube) {
72524
+ job.actor.name = youtube.provider_name || "YouTube";
72525
+ job.object = {
72526
+ type: "page",
72527
+ title: youtube.title,
72528
+ name: youtube.provider_name || "YouTube",
72529
+ description: "",
72530
+ image: youtubeOEmbedImage(youtube),
72531
+ url: job.actor.id,
72532
+ favicon: "/favicon.ico"
72533
+ };
72534
+ this.log.debug(`using youtube oEmbed fallback for ${job.actor.id}`);
72535
+ cb(null, job);
72536
+ return;
72537
+ }
72419
72538
  cb(err);
72420
72539
  });
72421
72540
  }
@@ -72432,4 +72551,4 @@ export {
72432
72551
  withDeadline
72433
72552
  };
72434
72553
 
72435
- //# debugId=636341FBC39FB0A464756E2164756E21
72554
+ //# debugId=98F3FC3874006CC764756E2164756E21