webextension-store-meta
Version:
Get browser extension(webextension) item meta from Chrome Web Store and Firefox add-ons.
280 lines (235 loc) • 7.18 kB
text/typescript
import type { RequestInit } from "undici";
import { parse, stringify } from "node:querystring";
import { DomHandler, type Node, isTag, isText } from "domhandler";
import { Parser } from "htmlparser2/lib/Parser";
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);
this._dom = 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;
}