UNPKG

webextension-store-meta

Version:

Get browser extension(webextension) item meta from Chrome Web Store, Firefox add-ons, and Microsoft Edge Add-ons.

286 lines (240 loc) 7.39 kB
import { parse, stringify } from "node:querystring"; import { DomHandler, isTag, isText, type Node } from "domhandler"; import { Parser } from "htmlparser2/lib/Parser"; import type { RequestInit } from "undici"; import { findOne, getText, queryOne } from "../utils/dom"; import { fetchText } from "../utils/fetch-text"; import { parseVersion } from "../utils/parse"; export type { RequestInit }; export interface ChromeWebStoreMeta { name: string | null; description: string | null; url: string | null; image: string | null; ratingValue: string | null; ratingCount: string | null; users: string | null; version: string | null; size: string | null; lastUpdated: string | null; } export interface ChromeWebStoreOptions { /** * Chrome Web Store extension ID * @example "cdonnmffkdaoajfknoeeecmchibpmkmg" */ id: string; /** * `undici.fetch` options * @see {@link https://undici.nodejs.org/#/?id=undicifetchinput-init-promise} */ options?: RequestInit; /** * Query string */ qs?: Record<string, string> | string; } export class ChromeWebStore { public id: string; public options?: RequestInit; public qs: string; private _dom: Node[] | null = null; private _cache = new Map<string, string | null>(); public constructor({ id, options, qs }: ChromeWebStoreOptions) { this.id = id; this.options = options; this.qs = stringify( typeof qs === "string" ? parse(qs.replace(/^\?/, "")) : qs || {}, ); } static async load(options: ChromeWebStoreOptions): Promise<ChromeWebStore> { const instance = new ChromeWebStore(options); await instance.load(); return instance; } async load(): Promise<ChromeWebStore> { const url = `https://chromewebstore.google.com/detail/${this.id}?${this.qs}`; const html = await fetchText(url, this.options); const handler = new DomHandler(); new Parser(handler).end(html); const href = findOne( (el) => el.tagName === "link" && el.attribs.rel === "canonical", handler.dom, )?.attribs.href; // Only keep DOM if it's an item page this._dom = href?.includes("/detail/") ? handler.dom : []; return this; } meta(): ChromeWebStoreMeta { return { name: this.name(), description: this.description(), url: this.url(), image: this.image(), ratingValue: this.ratingValue(), ratingCount: this.ratingCount(), users: this.users(), version: this.version(), size: this.size(), lastUpdated: this.lastUpdated(), }; } public name(): string | null { return this._og("og:title"); } public description(): string | null { return this._og("og:description"); } public url(): string | null { let url = this._og("og:url"); if (url) return url; const urlElem = findOne( (elem) => elem.name === "link" && elem.attribs.rel === "canonical", this.dom, ); if (urlElem) { url = urlElem.attribs.href; if (url) { this._cache.set("og:url", url); return url; } } return null; } public image(): string | null { return this._og("og:image"); } public ratingValue(): string | null { return this._parseRating("ratingValue"); } public ratingCount(): string | null { return this._parseRating("ratingCount"); } public users(): string | null { if (!this._cache.has("users")) { const container = queryOne(this.dom, "F9iKBc"); if (container) { for (let i = 0; i < container.children.length; i++) { const node = container.children[i]; if (isText(node)) { const content = getText(node); const match = /[\d,]+/.exec(content); if (match) { this._cache.set("users", match[0]); break; } } } } if (!this._cache.has("users")) { this._cache.set("users", null); } } return this._cache.get("users") ?? null; } public version(): string | null { if (!this._cache.has("version")) { const el = queryOne(this.dom, "nBZElf") || queryOne(this.dom, "N3EXSc"); this._cache.set( "version", (el && parseVersion(getText(el))) || parseVersionFromManifest(this.dom), ); } return this._cache.get("version") ?? null; } public size(): string | null { if (!this._cache.has("size")) { const el = queryOne(this.dom, "ZSMSLb"); this._cache.set("size", getText(el?.lastChild) || null); } return this._cache.get("size") ?? null; } public lastUpdated(): string | null { if (!this._cache.has("lastUpdated")) { const el = queryOne(this.dom, "uBIrad"); this._cache.set("lastUpdated", getText(el?.lastChild) || null); } return this._cache.get("lastUpdated") ?? null; } public get dom() { if (!this._dom) { throw new Error( "Item not loaded. Please run `await instance.load()` first.`", ); } return this._dom; } private _og(property: string): string | null { if (!this._cache.has(property)) { findOne((elem) => { if (elem.attribs.property == null) return false; this._cache.set(elem.attribs.property, elem.attribs.content); return elem.attribs.property === property; }, this.dom); if (!this._cache.has(property)) { this._cache.set(property, null); } } return this._cache.get(property) ?? null; } private _parseRating(key: "ratingValue" | "ratingCount"): string | null { if (!this._cache.has(key)) { const result = parseRating(this.dom); this._cache.set("ratingValue", result?.ratingValue ?? null); this._cache.set("ratingCount", result?.ratingCount ?? null); } return this._cache.get(key) ?? null; } } export default ChromeWebStore; function parseRating( dom: Node | Node[], ): { ratingValue: string | null; ratingCount: string | null } | null { const container = queryOne(dom, "j3zrsd"); if (container) { const result = { ratingValue: null as string | null, ratingCount: null as string | null, }; const ratingValueEl = queryOne(container, "Vq0ZA"); if (ratingValueEl) { const ratingValue = getText(ratingValueEl); if (/^\d+\.?\d*$/.test(ratingValue)) { result.ratingValue = ratingValue; } } const ratingCountEl = queryOne(container, "xJEoWe"); if (ratingCountEl) { result.ratingCount = getText(ratingCountEl).replace(/\s*ratings$/, ""); } if (result.ratingValue !== null || result.ratingCount !== null) { return result; } } return null; } function parseVersionFromManifest(maybeNode: Node | Node[]): string | null { const nodes = Array.isArray(maybeNode) ? maybeNode : [maybeNode]; for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (!isTag(node)) { continue; } if (node.tagName === "script") { const content = getText(node); if (content.includes("AF_initDataCallback")) { const match = /\\"version\\":\s*\\"([\d.]+)\\"/.exec(content); if (match) { return match[1]; } } } if (node.children.length > 0) { const version = parseVersionFromManifest(node.children); if (version) { return version; } } } return null; }