Douban Info Class

Parse Douban Info

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greasyfork.org/scripts/438042/1006692/Douban%20Info%20Class.js

// ==UserScript==
// @name               Douban Info Class
// @description        parse douban info
// @version            0.0.51
// @author             Secant(TYT@NexusHD)

// config cache encryption or compression
gs.config.ttl = 43200;
gs.config.encrypt = true;
gs.config.encrypter = (data) => LZString.compress(data);
gs.config.decrypter = (encryptedString) => LZString.decompress(encryptedString);

class MInfo {
  get info() {
    return (async () => {
      let info = {};
      for (let key in this) {
        info[key] = await this[key];
      }
      return info;
    })();
  }

  promisedGetterLazify(fun, propertyName, isEnumarable = true) {
    return {
      configurable: true,
      enumerable: isEnumarable,
      get: function () {
        Object.defineProperty(this, propertyName, {
          writable: false,
          enumerable: isEnumarable,
          value: fun(),
        });
        return this[propertyName];
      },
    };
  }

  async getResponseText(url, options = { headers: {} }, cacheConfig = {}) {
    let responseText = null;
    responseText = gs.get(url, cacheConfig);
    if (responseText) {
      return (async () => responseText)();
    } else {
      return new Promise((resolve) => {
        GM_xmlhttpRequest({
          method: "GET",
          url: url,
          headers: options.headers,
          timeout: options.timeout,
          onload: (resp) => {
            const { status, statusText, responseText } = resp;
            if (status === 200) {
              resolve(responseText);
              gs.set(url, responseText, cacheConfig);
            } else {
              console.warn(statusText);
              resolve(null);
            }
          },
          ontimeout: (e) => {
            console.warn(e);
            resolve(null);
          },
          onerror: (e) => {
            console.warn(e);
            resolve(null);
          },
        });
      });
    }
  }

  flushCache(force = false) {
    gs.flush(force);
  }
}

class DoubanInfo extends MInfo {
  static origin = "https://movie.douban.com";
  static timeout = 6000;
  static cacheConfig = {
    ttl: 43200,
    encrypt: true,
    encrypter: (data) => LZString.compress(data),
    decrypter: (encryptedString) => LZString.decompress(encryptedString),
  };

  constructor(id) {
    super();
    // define promised lazy getters
    Object.defineProperties(this, {
      id: this.promisedGetterLazify(async () => {
        return id;
      }, "id"),
      subjectPathname: this.promisedGetterLazify(
        async () => {
          const subjectPathname = `/subject/${await this.id}/`;
          return subjectPathname;
        },
        "subjectPathname",
        false
      ),
      awardPathname: this.promisedGetterLazify(
        async () => {
          const awardPathname = `/subject/${await this.id}/awards/`;
          return awardPathname;
        },
        "awardPathname",
        false
      ),
      celebrityPathname: this.promisedGetterLazify(
        async () => {
          const celebrityPathname = `/subject/${await this.id}/celebrities`;
          return celebrityPathname;
        },
        "celebrityPathname",
        false
      ),
      subjectDoc: this.promisedGetterLazify(
        async () => {
          const currentURL = new URL(window.location.href);
          let doc = null;
          if (
            currentURL.origin === DoubanInfo.origin &&
            currentURL.pathname === (await this.subjectPathname)
          ) {
            doc = document;
          } else {
            const url = new URL(
              await this.subjectPathname,
              DoubanInfo.origin
            ).toString();
            const options = {
              headers: {
                referrer: DoubanInfo.origin,
              },
              timeout: DoubanInfo.timeout,
            };
            const cacheConfig = DoubanInfo.cacheConfig;
            const responseText = await this.getResponseText(
              url,
              options,
              cacheConfig
            );
            if (responseText) {
              try {
                doc = new DOMParser().parseFromString(
                  responseText,
                  "text/html"
                );
              } catch (e) {
                console.warn(e);
              }
            } else {
              console.warn("no response text");
            }
          }
          return doc;
        },
        "subjectDoc",
        false
      ),
      awardDoc: this.promisedGetterLazify(
        async () => {
          const currentURL = new URL(window.location.href);
          let doc = null;
          if (
            currentURL.origin === DoubanInfo.origin &&
            currentURL.pathname === (await this.awardPathname)
          ) {
            doc = document;
          } else {
            const url = new URL(
              await this.awardPathname,
              DoubanInfo.origin
            ).toString();
            const options = {
              headers: {
                referrer: DoubanInfo.origin,
              },
              timeout: DoubanInfo.timeout,
            };
            const cacheConfig = DoubanInfo.cacheConfig;
            const responseText = await this.getResponseText(
              url,
              options,
              cacheConfig
            );
            if (responseText) {
              try {
                doc = new DOMParser().parseFromString(
                  responseText,
                  "text/html"
                );
              } catch (e) {
                console.warn(e);
              }
            } else {
              console.warn("no response text");
            }
          }
          return doc;
        },
        "awardDoc",
        false
      ),
      celebrityDoc: this.promisedGetterLazify(
        async () => {
          const currentURL = new URL(window.location.href);
          let doc = null;
          if (
            currentURL.origin === DoubanInfo.origin &&
            currentURL.pathname === (await this.celebrityPathname)
          ) {
            doc = document;
          } else {
            const url = new URL(
              await this.celebrityPathname,
              DoubanInfo.origin
            ).toString();
            const options = {
              headers: {
                referrer: DoubanInfo.origin,
              },
              timeout: DoubanInfo.timeout,
            };
            const cacheConfig = DoubanInfo.cacheConfig;
            const responseText = await this.getResponseText(
              url,
              options,
              cacheConfig
            );
            if (responseText) {
              try {
                doc = new DOMParser().parseFromString(
                  responseText,
                  "text/html"
                );
              } catch (e) {
                console.warn(e);
              }
            } else {
              console.warn("no response text");
            }
          }
          return doc;
        },
        "celebrityDoc",
        false
      ),
      linkingData: this.promisedGetterLazify(
        async () => {
          const doc = await this.subjectDoc;
          const ld =
            dirtyJson.parse(
              htmlEntities.decode(
                doc?.querySelector("head>script[type='application/ld+json']")
                  ?.textContent
              )
            ) || null;
          return ld;
        },
        "linkingData",
        false
      ),
      type: this.promisedGetterLazify(async () => {
        const ld = await this.linkingData;
        const type = ld?.["@type"]?.toLowerCase() || null;
        return type;
      }, "type"),
      poster: this.promisedGetterLazify(async () => {
        const doc = await this.subjectDoc;
        const ld = await this.linkingData;
        const posterFromDoc =
          doc?.querySelector("body #mainpic img")?.src || null;
        const posterFromMeta =
          doc?.querySelector("head>meta[property='og:image']")?.content || null;
        const posterFromLD = ld?.image || null;
        const poster =
          (posterFromDoc || posterFromMeta || posterFromLD)
            ?.replace("s_ratio_poster", "l_ratio_poster")
            .replace(/img\d+\.doubanio\.com/, "img9.doubanio.com")
            .replace(/\.webp$/i, ".jpg") || null;
        return poster;
      }, "poster"),
      title: this.promisedGetterLazify(
        async () => {
          const doc = await this.subjectDoc;
          const ld = await this.linkingData;
          const titleFromDoc =
            doc?.querySelector("body #content h1>span[property]")
              ?.textContent || null;
          const titleFromMeta =
            doc?.querySelector("head>meta[property='og:title']")?.content ||
            null;
          const titleFromLD = ld?.name || null;
          const title = titleFromDoc || titleFromMeta || titleFromLD;
          return title;
        },
        "title",
        false
      ),
      year: this.promisedGetterLazify(async () => {
        const doc = await this.subjectDoc;
        const year =
          parseInt(
            doc
              ?.querySelector("body #content>h1>span.year")
              ?.textContent.slice(1, -1) || 0,
            10
          ) || null;
        return year;
      }, "year"),
      chineseTitle: this.promisedGetterLazify(async () => {
        const doc = await this.subjectDoc;
        const chineseTitle = doc?.title?.slice(0, -5);
        return chineseTitle;
      }, "chineseTitle"),
      originalTitle: this.promisedGetterLazify(async () => {
        let originalTitle;
        if (await this.isChinese) {
          originalTitle = await this.chineseTitle;
        } else {
          originalTitle = (await this.title)
            ?.replace(await this.chineseTitle, "")
            .trim();
        }
        return originalTitle;
      }, "originalTitle"),
      aka: this.promisedGetterLazify(async () => {
        const doc = await this.subjectDoc;
        const priority = (t) =>
          /\(港.?台\)/.test(t) ? 1 : /\((?:[港台]|香港|台湾)\)/.test(t) ? 2 : 3;
        let aka =
          [...(doc?.querySelectorAll("body #info span.pl") || [])]
            .find((n) => n.textContent.includes("又名"))
            ?.nextSibling?.textContent.split("/")
            .map((t) => t.trim())
            .sort((t1, t2) => priority(t1) - priority(t2)) || [];
        if (aka.length === 0) {
          aka = null;
        }
        return aka;
      }, "aka"),
      isChinese: this.promisedGetterLazify(
        async () => {
          let isChinese = false;
          if ((await this.title) === (await this.chineseTitle)) {
            isChinese = true;
          }
          return isChinese;
        },
        "isChinese",
        false
      ),
      region: this.promisedGetterLazify(async () => {
        const doc = await this.subjectDoc;
        let region =
          [...(doc?.querySelectorAll("body #info span.pl") || [])]
            .find((n) => n.textContent.includes("制片国家/地区"))
            ?.nextSibling?.textContent.split("/")
            .map((r) => r.trim()) || [];
        if (region.length === 0) {
          region = null;
        }
        return region;
      }, "region"),
      language: this.promisedGetterLazify(async () => {
        const doc = await this.subjectDoc;
        let language =
          [...(doc?.querySelectorAll("body #info span.pl") || [])]
            .find((n) => n.textContent.includes("语言"))
            ?.nextSibling?.textContent.split("/")
            .map((l) => l.trim()) || [];
        if (language.length === 0) {
          language = null;
        }
        return language;
      }, "language"),
      genre: this.promisedGetterLazify(async () => {
        const doc = await this.subjectDoc;
        const ld = await this.linkingData;
        let genreFromDoc = [
          ...(doc?.querySelectorAll('body #info span[property="v:genre"]') ||
            []),
        ].map((g) => g.textContent.trim());
        if (genreFromDoc.length === 0) {
          genreFromDoc = null;
        }
        let genreFromLD = ld?.genre || [];
        if (genreFromLD.length === 0) {
          genreFromLD = null;
        }
        const genre = genreFromDoc || genreFromLD;
        return genre;
      }, "genre"),
      duration: this.promisedGetterLazify(async () => {
        const doc = await this.subjectDoc;
        const ld = await this.linkingData;
        const type = await this.type;
        let movieDurationFromDoc = null,
          episodeDurationFromDoc = null;
        if (type === "movie") {
          let durationString = "";
          let node =
            doc?.querySelector('body span[property="v:runtime"]') || null;
          while (node && node.nodeName !== "BR") {
            durationString += node.textContent;
            node = node.nextSibling;
          }
          if (durationString !== "") {
            movieDurationFromDoc = durationString
              .split("/")
              .map((str) => {
                str = str.trim();
                const strOI = splitOI(str);
                const duration = parseInt(strOI.o || 0, 10) * 60 || null;
                const whereabouts = strOI.i || null;
                return {
                  duration,
                  whereabouts,
                };
              })
              .filter((d) => d.duration);
            if (movieDurationFromDoc.length === 0) {
              movieDurationFromDoc = null;
            }
          }
        } else if (type === "tvseries") {
          const episodeDurationSecondsFromDoc =
            parseInt(
              [...(doc?.querySelectorAll("body #info span.pl") || [])]
                .find((n) => n.textContent.includes("单集片长"))
                ?.nextSibling?.textContent.trim() || 0,
              10
            ) * 60 || null;
          if (episodeDurationSecondsFromDoc) {
            episodeDurationFromDoc = [
              {
                duration: episodeDurationSecondsFromDoc,
                whereabouts: null,
              },
            ];
          }
        }
        let durationFromMeta = null;
        const durationSecondsFromMeta =
          parseInt(
            doc?.querySelector("head>meta[property='video:duration']")
              ?.content || 0,
            10
          ) || null;
        if (durationSecondsFromMeta) {
          durationFromMeta = [
            {
              duration: durationSecondsFromMeta,
              whereabouts: null,
            },
          ];
        }
        let durationFromLD = null;
        const durationSecondsFromLD =
          parseInt(
            ld?.duration?.replace(
              /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$/,
              (_, p1, p2, p3) => {
                return (
                  parseInt(p1 || 0, 10) * 3600 +
                  parseInt(p2 || 0, 10) * 60 +
                  parseInt(p3 || 0, 10)
                ).toString();
              }
            ) || 0,
            10
          ) || null;
        if (durationSecondsFromLD) {
          durationFromLD = [
            {
              duration: durationSecondsFromLD,
              whereabouts: null,
            },
          ];
        }
        const duration =
          movieDurationFromDoc ||
          episodeDurationFromDoc ||
          durationFromMeta ||
          durationFromLD;
        return duration;
      }, "duration"),
      datePublished: this.promisedGetterLazify(async () => {
        const doc = await this.subjectDoc;
        const ld = await this.linkingData;
        let datePublishedFromDoc = [
          ...(doc?.querySelectorAll(
            'body #info span[property="v:initialReleaseDate"]'
          ) || []),
        ]
          .map((e) => {
            const str = e.textContent.trim();
            const strOI = splitOI(str);
            if (!strOI.o) {
              return null;
            } else {
              return {
                date: new Date(strOI.o),
                whereabouts: strOI.i || null,
              };
            }
          })
          .filter((e) => !!e)
          .sort((d1, d2) => {
            d1.date - d2.date;
          });
        if (datePublishedFromDoc.length === 0) {
          datePublishedFromDoc = null;
        }
        const datePublishedStringFromLD = ld?.datePublished || null;
        let datePublishedFromLD = null;
        if (datePublishedStringFromLD) {
          datePublishedFromLD = [
            { date: new Date(datePublishedStringFromLD), whereabouts: null },
          ];
        }
        const datePublished = datePublishedFromDoc || datePublishedFromLD;
        return datePublished;
      }, "datePublished"),
      episodeCount: this.promisedGetterLazify(async () => {
        if ((await this.type) === "tvseries") {
          const doc = await this.subjectDoc;
          const episodeCount =
            parseInt(
              [...(doc?.querySelectorAll("body #info span.pl") || [])]
                .find((n) => n.textContent.includes("集数"))
                ?.nextSibling?.textContent.trim() || 0,
              10
            ) || null;
          return episodeCount;
        } else {
          return null;
        }
      }, "episodeCount"),
      tag: this.promisedGetterLazify(async () => {
        const doc = await this.subjectDoc;
        let tag = [
          ...(doc?.querySelectorAll("body div.tags-body>a") || []),
        ].map((t) => t.textContent);
        if (tag.length === 0) {
          tag = null;
        }
        return tag;
      }, "tag"),
      rating: this.promisedGetterLazify(async () => {
        const doc = await this.subjectDoc;
        let ratingFromDoc = null;
        let countFromDoc =
          parseInt(
            doc?.querySelector('body #interest_sectl [property="v:votes"]')
              ?.textContent || 0,
            10
          ) || null;
        let valueFromDoc =
          parseFloat(
            doc?.querySelector('body #interest_sectl [property="v:average"]')
              ?.textContent || 0
          ) || null;
        if (countFromDoc && valueFromDoc) {
          ratingFromDoc = {
            count: countFromDoc,
            value: valueFromDoc,
            max: 10,
          };
        }
        const ld = await this.linkingData;
        let ratingFromLD = null;
        let countFromLD =
          parseInt(ld?.aggregateRating?.ratingCount || 0, 10) || null;
        let valueFromLD =
          parseFloat(ld?.aggregateRating?.ratingValue || 0) || null;
        if (countFromLD && valueFromLD) {
          ratingFromLD = {
            count: countFromLD,
            value: valueFromLD,
            max: 10,
          };
        }
        const rating = ratingFromDoc || ratingFromLD;
        return rating;
      }, "rating"),
      description: this.promisedGetterLazify(async () => {
        const doc = await this.subjectDoc;
        const ld = await this.linkingData;
        const descriptionFromDoc =
          [
            ...(doc?.querySelector(
              'body #link-report>[property="v:summary"],body #link-report>span.all.hidden'
            )?.childNodes || []),
          ]
            .filter((e) => e.nodeType === 3)
            .map((e) => e.textContent.trim())
            .join("\n") || null;
        const descriptionFromMeta =
          doc?.querySelector("head>meta[property='og:description']")?.content ||
          null;
        const descriptionFromLD = ld?.description || null;
        const description =
          descriptionFromDoc || descriptionFromMeta || descriptionFromLD;
        return description;
      }, "description"),
      imdbId: this.promisedGetterLazify(async () => {
        const doc = await this.subjectDoc;
        let imdbId = null;
        if (
          doc?.querySelector("body #season option:checked")?.textContent !==
            "1" ||
          false
        ) {
          const doubanId =
            doc.querySelector("body #season option:first-of-type")?.value ||
            null;
          if (doubanId) {
            const firstSeasonDoubanInfo = new DoubanInfo(doubanId);
            imdbId = await firstSeasonDoubanInfo.imdbId;
          }
        } else {
          imdbId =
            [...(doc?.querySelectorAll("body #info span.pl") || [])]
              .find((n) => n.textContent.includes("IMDb:"))
              ?.nextSibling?.textContent.match(/tt(\d+)/)?.[1] || null;
        }
        return imdbId;
      }, "imdbId"),
      awardData: this.promisedGetterLazify(async () => {
        const doc = await this.awardDoc;
        let awardData = [...(doc?.querySelectorAll("body div.awards") || [])]
          .map((awardNode) => {
            const event =
              awardNode?.querySelector(".hd>h2 a")?.textContent.trim() || null;
            const year =
              parseInt(
                awardNode
                  ?.querySelector(".hd>h2 .year")
                  ?.textContent.match(/\d+/)?.[0] || 0,
                10
              ) || null;
            let award = [...(awardNode?.querySelectorAll(".award") || [])]
              .map((a) => {
                const name =
                  a.querySelector("li:first-of-type")?.textContent.trim() ||
                  null;
                let recipient = a
                  .querySelector("li:nth-of-type(2)")
                  ?.textContent.split("/")
                  .map((p) => p.trim() || null)
                  .filter((p) => !!p);
                if (recipient.length === 0) {
                  recipient = null;
                }
                if (name) {
                  return {
                    name,
                    recipient,
                  };
                } else {
                  return null;
                }
              })
              .filter((a) => !!a);
            if (award.length === 0) {
              award = null;
            }
            if (event) {
              return {
                event,
                year,
                award,
              };
            } else {
              return null;
            }
          })
          .filter((a) => !!a);
        if (awardData.length === 0) {
          awardData = null;
        }
        return awardData;
      }, "awardData"),
      celebrityData: this.promisedGetterLazify(async () => {
        const doc = await this.celebrityDoc;
        let celebrityData = [
          ...(doc?.querySelectorAll("body #celebrities>div.list-wrapper") ||
            []),
        ]
          .map((o) => {
            const occupation =
              o.querySelector("h2")?.textContent.trim() || null;
            let occupationCh = null;
            let occupationEn = null;
            if (occupation) {
              const occupationSplitted = splitChEn(occupation);
              occupationCh = occupationSplitted.ch;
              occupationEn = occupationSplitted.en;
            }
            const celebrities = [...(o.querySelectorAll("li.celebrity") || [])]
              .map((c) => {
                const name =
                  c.querySelector(".info>.name")?.textContent.trim() || null;
                let nameCh = null;
                let nameEn = null;
                if (name) {
                  const nameSplitted = splitChEn(name);
                  nameCh = nameSplitted.ch;
                  nameEn = nameSplitted.en;
                }
                const creditAndAttribute =
                  c.querySelector(".info>.role")?.textContent.trim() || null;
                let credit = null;
                let attribute = null;
                let creditCh = null;
                let creditEn = null;
                if (creditAndAttribute) {
                  const creditAndAttributeSplitted =
                    splitOI(creditAndAttribute);
                  credit = creditAndAttributeSplitted.o;
                  attribute = creditAndAttributeSplitted.i;
                  if (credit) {
                    const creditSplitted = splitChEn(credit);
                    creditCh = creditSplitted.ch;
                    creditEn = creditSplitted.en;
                  }
                }
                if (!credit && occupation) {
                  credit = occupation;
                  creditCh = occupationCh;
                  creditEn = occupationEn;
                }
                if (!occupation && !name && !credit && !attribute) {
                  return null;
                } else {
                  return {
                    occupation: {
                      value: occupation,
                      ch: occupationCh,
                      en: occupationEn,
                    },
                    name: {
                      value: name,
                      ch: nameCh,
                      en: nameEn,
                    },
                    credit: {
                      value: credit,
                      ch: creditCh,
                      en: creditEn,
                    },
                    attribute: {
                      value: attribute,
                    },
                  };
                }
              })
              .filter((c) => !!c);
            return celebrities;
          })
          .flat();
        if (celebrityData.length === 0) {
          celebrityData = null;
        }
        return celebrityData;
      }, "celebrityData"),
    });
  }
}

class IMDbInfo extends MInfo {
  static originalOrigin = "https://www.imdb.com";
  static originalPluginOrigin = "http://p.media-imdb.com";
  static proxyOrigin = "https://proxy.secant.workers.dev";
  static isProxified = true;
  static origin = IMDbInfo.isProxified
    ? IMDbInfo.proxyOrigin
    : IMDbInfo.originalOrigin;
  static pluginOrigin = IMDbInfo.isProxified
    ? IMDbInfo.proxyOrigin
    : IMDbInfo.originalPluginOrigin;
  static timeout = 6000;
  static cacheConfig = {
    ttl: 43200,
    encrypt: true,
    encrypter: (data) => LZString.compress(data),
    decrypter: (encryptedString) => LZString.decompress(encryptedString),
  };

  constructor(id) {
    super();
    // define promised lazy getters
    Object.defineProperties(this, {
      id: this.promisedGetterLazify(async () => {
        return id;
      }, "id"),
      titlePathname: this.promisedGetterLazify(
        async () => {
          let titlePathname;
          if (IMDbInfo.isProxified) {
            titlePathname = `/worker/proxy/www.imdb.com/title/tt${await this
              .id}/`;
          } else {
            titlePathname = `/title/tt${await this.id}/`;
          }
          return titlePathname;
        },
        "titlePathname",
        false
      ),
      releaseInfoPathname: this.promisedGetterLazify(
        async () => {
          let releaseInfoPathname;
          if (IMDbInfo.isProxified) {
            releaseInfoPathname = `/worker/proxy/www.imdb.com/title/tt${await this
              .id}/releaseinfo`;
          } else {
            releaseInfoPathname = `/title/tt${await this.id}/releaseinfo`;
          }
          return releaseInfoPathname;
        },
        "releaseInfoPathname",
        false
      ),
      pluginPathname: this.promisedGetterLazify(
        async () => {
          let pluginPathname;
          if (IMDbInfo.isProxified) {
            pluginPathname = `/worker/proxy/p.media-imdb.com/static-content/documents/v1/title/tt${await this
              .id}/ratings%253Fjsonp=imdb.rating.run:imdb.api.title.ratings/data.json`;
          } else {
            pluginPathname = `/static-content/documents/v1/title/tt${await this
              .id}/ratings%3Fjsonp=imdb.rating.run:imdb.api.title.ratings/data.json`;
          }
          return pluginPathname;
        },
        "pluginPathname",
        false
      ),
      titleDoc: this.promisedGetterLazify(
        async () => {
          const currentURL = new URL(window.location.href);
          let doc = null;
          if (
            currentURL.origin === IMDbInfo.origin &&
            currentURL.pathname === (await this.titlePathname)
          ) {
            doc = document;
          } else {
            const url = new URL(
              await this.titlePathname,
              IMDbInfo.origin
            ).toString();
            const options = {
              headers: {
                referrer: IMDbInfo.origin,
              },
              timeout: IMDbInfo.timeout,
            };
            const cacheConfig = IMDbInfo.cacheConfig;
            const responseText = await this.getResponseText(
              url,
              options,
              cacheConfig
            );
            if (responseText) {
              try {
                doc = new DOMParser().parseFromString(
                  responseText,
                  "text/html"
                );
              } catch (e) {
                console.warn(e);
              }
            } else {
              console.warn("no response text");
            }
          }
          return doc;
        },
        "titleDoc",
        false
      ),
      releaseInfoDoc: this.promisedGetterLazify(
        async () => {
          const currentURL = new URL(window.location.href);
          let doc = null;
          if (
            currentURL.origin === IMDbInfo.origin &&
            currentURL.pathname === (await this.releaseInfoPathname)
          ) {
            doc = document;
          } else {
            const url = new URL(
              await this.releaseInfoPathname,
              IMDbInfo.origin
            ).toString();
            const options = {
              headers: {
                referrer: IMDbInfo.origin,
              },
              timeout: IMDbInfo.timeout,
            };
            const cacheConfig = IMDbInfo.cacheConfig;
            const responseText = await this.getResponseText(
              url,
              options,
              cacheConfig
            );
            if (responseText) {
              try {
                doc = new DOMParser().parseFromString(
                  responseText,
                  "text/html"
                );
              } catch (e) {
                console.warn(e);
              }
            } else {
              console.warn("no response text");
            }
          }
          return doc;
        },
        "titleDoc",
        false
      ),
      pluginResponseText: this.promisedGetterLazify(
        async () => {
          let responseText = null;
          const url = new URL(
            await this.pluginPathname,
            IMDbInfo.pluginOrigin
          ).toString();
          const options = {
            headers: {
              referrer: IMDbInfo.pluginOrigin,
            },
            timeout: IMDbInfo.timeout,
          };
          const cacheConfig = IMDbInfo.cacheConfig;
          const pluginResponseText = await this.getResponseText(
            url,
            options,
            cacheConfig
          );
          if (!pluginResponseText) {
            console.warn("no response text");
          }
          return pluginResponseText;
        },
        "pluginResponseText",
        false
      ),
      linkingData: this.promisedGetterLazify(
        async () => {
          const doc = await this.titleDoc;
          const ld =
            dirtyJson.parse(
              htmlEntities.decode(
                doc?.querySelector("head>script[type='application/ld+json']")
                  ?.textContent
              )
            ) || null;
          return ld;
        },
        "linkingData",
        false
      ),
    });
  }
}

class MtimeInfo extends MInfo {
  constructor(id) {
    super();
    // define promised lazy getters
    Object.defineProperties(this, {
      id: this.promisedGetterLazify(async () => {
        return id;
      }, "id"),
    });
  }
}

function splitOI(word) {
  word = word.trim();
  const splitOIRegExp = /^(?<o>.*?)(?:\((?<i>[^\(]*?)\))?$/;
  let { o = null, i = null } = word.match(splitOIRegExp)?.groups || {};
  return {
    o: o ? o.trim() : o,
    i: i ? i.trim() : i,
  };
}

function splitChEn(word) {
  word = word.trim();
  const splitChEnRegExp =
    /^(?<ch>.*\p{Script=Han}[^\s\p{Script=Han}]*)(?: +(?<en1>[^\p{Script=Han}]*))?$|^(?<en2>[^\p{Script=Han}]*)$/u;
  const splitEnEnRegExp = /^(?<en>[^\p{Script=Han}]*?) +\k<en>$/u;
  let {
    ch = null,
    en1 = null,
    en2 = null,
  } = word.match(splitChEnRegExp)?.groups || {};
  let en = (en1 || en2)?.trim() || null;
  if (ch === null) {
    en = en.match(splitEnEnRegExp)?.groups.en || en;
  }
  return {
    ch: ch ? ch.trim() : ch,
    en: en ? en.trim() : en,
  };
}