Pinterest++

为 Pinterest 增加原图下载与保存状态按钮。

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Pinterest++
// @name:en      Pinterest++
// @name:zh-CN   Pinterest++
// @namespace    https://github.com/zker67/pinterest-plus-plus
// @description  为 Pinterest 增加原图下载与保存状态按钮。
// @description:zh-CN 为 Pinterest 增加原图下载与保存状态按钮。
// @description:en Add compact buttons for original image download and save state.
// @match        https://*.pinterest.com/*
// @match        https://*.pinterest.at/*
// @match        https://*.pinterest.ca/*
// @match        https://*.pinterest.ch/*
// @match        https://*.pinterest.cl/*
// @match        https://*.pinterest.co.kr/*
// @match        https://*.pinterest.co.uk/*
// @match        https://*.pinterest.com.au/*
// @match        https://*.pinterest.com.mx/*
// @match        https://*.pinterest.de/*
// @match        https://*.pinterest.dk/*
// @match        https://*.pinterest.es/*
// @match        https://*.pinterest.fr/*
// @match        https://*.pinterest.ie/*
// @match        https://*.pinterest.info/*
// @match        https://*.pinterest.it/*
// @match        https://*.pinterest.jp/*
// @match        https://*.pinterest.nz/*
// @match        https://*.pinterest.ph/*
// @match        https://*.pinterest.pt/*
// @match        https://*.pinterest.se/*
// @author       zker67, TiLied
// @version      0.8.28
// @license      MIT
// @grant        GM_download
// @grant        GM.download
// @grant        GM_xmlhttpRequest
// @grant        GM.xmlHttpRequest
// @grant        GM_setClipboard
// @grant        GM.setClipboard
// @connect      i.pinimg.com
// @connect      *.pinimg.com
// @run-at       document-idle
// ==/UserScript==

class PinterestPlus {
	_Observer = null;
	_PinCache = new Map();
	_MediaSelector = "img, video, video source, [style*='pinimg.com']";

	constructor() {
		console.log("Pinterest Plus v" + GM.info.script.version + " initialization");
		this._SetCSS();
		this._StartDownloadObserver();
	}

	_SetCSS() {
		const css = `
			.ppCardOverlayHost {
				position: relative !important;
			}

			.ppNativeSaveHidden {
				height: 1px !important;
				opacity: 0 !important;
				overflow: hidden !important;
				pointer-events: none !important;
				position: absolute !important;
				width: 1px !important;
			}

			.ppCompactActionBar {
				align-items: center;
				display: inline-flex;
				gap: 8px;
				opacity: 0;
				pointer-events: auto !important;
				position: absolute;
				right: 12px;
				top: 12px;
				transform: translateY(-2px);
				transition: opacity 160ms ease, transform 160ms ease;
				z-index: 1003;
			}

			.ppCardOverlayHost:hover > .ppCompactActionBar,
			.ppCardOverlayHost:focus-within > .ppCompactActionBar {
				opacity: 1;
				transform: translateY(0);
			}

			.ppIconButton {
				align-items: center;
				backdrop-filter: blur(10px);
				background: #fff;
				border: 0;
				border-radius: 999px;
				box-shadow: 0 1px 4px rgba(0, 0, 0, 0.16);
				box-sizing: border-box;
				color: #111;
				cursor: pointer;
				display: inline-flex;
				height: 40px;
				justify-content: center;
				padding: 0;
				pointer-events: auto !important;
				transition: background-color 160ms ease, box-shadow 160ms ease, color 160ms ease, transform 160ms ease;
				width: 40px;
			}

			.ppIconButton:hover,
			.ppIconButton:focus-visible {
				background: #f1f1f1;
				box-shadow: 0 2px 8px rgba(0, 0, 0, 0.22);
				transform: scale(1.04);
			}

			.ppIconButton svg {
				display: block;
				height: 12px;
				width: 12px;
			}

			.ppIconButton[disabled] {
				cursor: progress;
				opacity: 0.7;
				transform: none;
			}

			.ppSaveButton {
				background: #e60023;
				border-radius: 24px;
				box-shadow: none;
				color: #fff;
				font-size: 16px;
				font-weight: 700;
				min-width: 64px;
				padding: 0 16px;
				width: auto;
			}

			.ppSaveButton:hover,
			.ppSaveButton:focus-visible,
			.ppSaveButton.ppSaved:hover,
			.ppSaveButton.ppSaved:focus-visible {
				background: #ad081b;
				box-shadow: none;
				transform: none;
			}

			.ppSaveButtonText {
				line-height: 1;
				white-space: nowrap;
			}

			.ppDownloadButton.ppWorking {
				background: #111;
				color: #fff;
			}

			.ppDownloadButton.ppDone {
				background: #0a8f3c;
				color: #fff;
			}

			.ppCopyButton.ppCopied {
				background: #0a8f3c;
				color: #fff;
			}

			.ppSaveButton.ppSaved {
				background: #e60023;
				color: #fff;
			}

			.ppDetailDownloadButton {
				flex: 0 0 auto;
				margin-right: 8px;
			}
		`;

		const style = document.createElement("style");
		style.type = "text/css";
		style.id = "pp-style";
		style.textContent = css;
		(document.head || document.documentElement).appendChild(style);
	}

	_StartDownloadObserver() {
		this._InjectDownloadButtons(document);
		this._InjectDetailDownloadButtons(document);

		this._Observer = new MutationObserver((mutations) => {
			for (const mutation of mutations) {
				for (const node of mutation.addedNodes) {
					if (node.nodeType === Node.ELEMENT_NODE) {
						this._InjectDownloadButtons(node);
						this._InjectDetailDownloadButtons(node);
					}
				}
			}
		});

		this._Observer.observe(document.documentElement, {
			childList: true,
			subtree: true,
		});
	}

	_InjectDownloadButtons(root) {
		const cards = new Set([
			...(root.matches?.("[data-test-id='pinWrapper'], [data-test-id='pin'], [data-test-id='pinrep']") ? [root] : []),
			...(root.querySelectorAll?.("[data-test-id='pinWrapper'], [data-test-id='pin'], [data-test-id='pinrep']") || []),
		]);
		const quickSaveButtons = [
			...(root.matches?.("div[data-test-id='quick-save-button']") ? [root] : []),
			...(root.querySelectorAll?.("div[data-test-id='quick-save-button']") || []),
			...(root.matches?.("[data-test-id='pinWrapper'] button[aria-label='保存'], [data-test-id='pinWrapper'] button[aria-label='已收藏']") ? [root.closest("[data-test-id='pinWrapper']")] : []),
			...(root.querySelectorAll?.("[data-test-id='pinWrapper'] button[aria-label='保存'], [data-test-id='pinWrapper'] button[aria-label='已收藏']") || []),
		];

		for (const quickSave of quickSaveButtons) {
			const saveHost = quickSave.matches?.("button") ? quickSave.parentElement : quickSave;
			const card = this._FindPinCard(saveHost);

			if (card != null) {
				cards.add(card);
			}
		}

		if (cards.size === 0) {
			return;
		}

		for (const card of cards) {
			if (!this._IsDownloadableCard(card)) {
				continue;
			}

			card.classList.add("ppCardOverlayHost");

			const existingActionBar = card.querySelector(":scope > .ppCompactActionBar");
			const existingSaveButton = existingActionBar?.querySelector(".ppSaveButton");
			const saveHost = this._FindNativeSaveHost(card);

			if (saveHost != null) {
				saveHost.classList.add("ppNativeSaveHidden");
			}

			if (existingActionBar != null) {
				if (existingSaveButton != null) {
					this._SyncSaveState(saveHost, existingSaveButton);
				}
				continue;
			}

			const actionBar = document.createElement("div");
			actionBar.className = "ppCompactActionBar";

			const copyButton = this._CreateIconButton("ppCopyButton", "复制原图链接", this._CopyIcon());
			const downloadButton = this._CreateIconButton("ppDownloadButton", "下载原图", this._DownloadIcon());
			const saveButton = this._CreateSaveButton();

			const stop = (e) => {
				e.preventDefault();
				e.stopPropagation();
				e.stopImmediatePropagation();
			};

			copyButton.addEventListener("mousedown", stop, true);
			copyButton.addEventListener("mouseup", stop, true);
			copyButton.addEventListener("click", async (e) => {
				stop(e);
				await this._CopyFromCard(card, copyButton);
			}, true);

			downloadButton.addEventListener("mousedown", stop, true);
			downloadButton.addEventListener("mouseup", stop, true);
			downloadButton.addEventListener("click", async (e) => {
				stop(e);
				await this._DownloadFromCard(card, downloadButton);
			}, true);

			saveButton.addEventListener("mousedown", stop, true);
			saveButton.addEventListener("mouseup", stop, true);
			saveButton.addEventListener("click", (e) => {
				stop(e);
				this._ClickNativeSave(this._FindNativeSaveHost(card), saveButton);
			}, true);

			this._SyncSaveState(saveHost, saveButton);

			actionBar.appendChild(copyButton);
			actionBar.appendChild(downloadButton);
			actionBar.appendChild(saveButton);
			card.appendChild(actionBar);
		}
	}

	_IsDownloadableCard(card) {
		return card.querySelector("a[href*='/pin/']") != null
			&& card.querySelector(this._MediaSelector) != null;
	}

	_FindNativeSaveHost(card) {
		const button = [
			...card.querySelectorAll(
				"div[data-test-id='quick-save-button'] button," +
				"button[aria-label='保存']," +
				"button[aria-label='已收藏']," +
				"button[aria-label='Save']," +
				"button[aria-label='Saved']"
			),
		].find((item) => item.closest(".ppCompactActionBar") == null);

		if (button == null) {
			return null;
		}

		const saveHost = button.closest("div[data-test-id='quick-save-button']")
			|| button.closest("div[data-test-id='pointer-events-wrapper']")
			|| button.parentElement;

		return saveHost === card || saveHost?.contains?.(card) ? null : saveHost;
	}

	_InjectDetailDownloadButtons(root) {
		const detailSaveButtons = [
			...(root.matches?.("div[data-test-id='PinBetterSaveButton']") ? [root] : []),
			...(root.querySelectorAll?.("div[data-test-id='PinBetterSaveButton']") || []),
		];

		if (detailSaveButtons.length === 0) {
			return;
		}

		for (const saveWrapper of detailSaveButtons) {
			const saveColumn = saveWrapper.closest(".AzXCPS") || saveWrapper.parentElement;
			const row = saveColumn?.parentElement;

			if (saveColumn == null || row == null || row.querySelector(":scope > .ppDetailDownloadButton") != null) {
				continue;
			}

			const downloadButton = this._CreateIconButton("ppDownloadButton ppDetailDownloadButton", "下载原图", this._DownloadIcon());

			const stop = (e) => {
				e.preventDefault();
				e.stopPropagation();
				e.stopImmediatePropagation();
			};

			downloadButton.addEventListener("mousedown", stop, true);
			downloadButton.addEventListener("mouseup", stop, true);
			downloadButton.addEventListener("click", async (e) => {
				stop(e);
				await this._DownloadFromDetail(downloadButton);
			}, true);

			row.insertBefore(downloadButton, saveColumn);
		}
	}

	_CreateIconButton(className, label, iconMarkup) {
		const button = document.createElement("button");
		button.type = "button";
		button.className = `ppIconButton ${className}`;
		button.title = label;
		button.setAttribute("aria-label", label);
		button.innerHTML = iconMarkup;
		return button;
	}

	_CreateSaveButton() {
		const button = document.createElement("button");
		button.type = "button";
		button.className = "ppIconButton ppSaveButton";
		button.title = "保存";
		button.setAttribute("aria-label", "保存");
		button.innerHTML = "<span class='ppSaveButtonText'>保存</span>";
		return button;
	}

	_DownloadIcon() {
		return `
			<svg aria-hidden="true" viewBox="0 0 24 24" fill="none">
				<path d="M12 3v11" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/>
				<path d="m7.5 10.5 4.5 4.5 4.5-4.5" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
				<path d="M5 19h14" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/>
			</svg>
		`;
	}

	_CopyIcon() {
		return `
			<svg aria-hidden="true" viewBox="0 0 24 24" fill="none">
				<path d="M9 7h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2Z" stroke="currentColor" stroke-width="2.2" stroke-linejoin="round"/>
				<path d="M5 15H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
			</svg>
		`;
	}

	_FindPinCard(node) {
		const direct = node.closest("[data-test-id='pinWrapper'], [data-test-id='pin'], [data-test-id='pinrep']");

		if (direct != null) {
			return direct;
		}

		let current = node.parentElement;

		for (let i = 0; i < 12 && current != null; i++) {
			if (current.querySelector("a[href*='/pin/']") != null) {
				return current;
			}

			current = current.parentElement;
		}

		return null;
	}

	async _DownloadFromCard(card, button) {
		try {
			button.disabled = true;
			button.classList.add("ppWorking");

			const result = await this._ResolveCardOriginal(card);

			if (result.urls.length === 0) {
				throw new Error("Cannot resolve original image url.");
			}

			await this._DownloadFirstAvailable(result.urls, result.filename);
			button.classList.add("ppDone");
			setTimeout(() => {
				button.classList.remove("ppDone");
				button.classList.remove("ppWorking");
				button.disabled = false;
			}, 1000);
		}
		catch (error) {
			console.error("Pinterest Plus download failed:", error);
			button.classList.remove("ppWorking");
			button.disabled = false;
		}
	}

	async _CopyFromCard(card, button) {
		try {
			button.disabled = true;

			const result = await this._ResolveCardOriginal(card);
			const url = result.urls[0];

			if (url == null) {
				throw new Error("Cannot resolve original media url.");
			}

			await this._CopyText(url);
			button.classList.add("ppCopied");
			setTimeout(() => {
				button.classList.remove("ppCopied");
				button.disabled = false;
			}, 1000);
		}
		catch (error) {
			console.error("Pinterest Plus copy failed:", error);
			button.disabled = false;
		}
	}

	async _DownloadFromDetail(button) {
		try {
			button.disabled = true;

			const result = await this._ResolveDetailOriginal();

			if (result.urls.length === 0) {
				throw new Error("Cannot resolve detail original image url.");
			}

			await this._DownloadFirstAvailable(result.urls, result.filename);
			button.classList.add("ppDone");
			setTimeout(() => {
				button.classList.remove("ppDone");
				button.disabled = false;
			}, 1000);
		}
		catch (error) {
			console.error("Pinterest Plus detail download failed:", error);
			button.disabled = false;
		}
	}

	_ClickNativeSave(quickSave, saveButton) {
		if (quickSave == null) {
			return;
		}

		const nativeButton = quickSave.querySelector("button");

		if (nativeButton == null) {
			return;
		}

		nativeButton.click();
		setTimeout(() => this._SyncSaveState(quickSave, saveButton), 300);
		setTimeout(() => this._SyncSaveState(quickSave, saveButton), 900);
		setTimeout(() => this._SyncSaveState(quickSave, saveButton), 1800);
	}

	_SyncSaveState(quickSave, saveButton) {
		if (quickSave == null) {
			saveButton.classList.remove("ppSaved");
			saveButton.setAttribute("aria-label", "保存");
			saveButton.title = "保存";
			this._SetSaveButtonText(saveButton, "保存");
			return;
		}

		const saved = this._IsNativeSaved(quickSave);
		const label = saved ? "已收藏" : "保存";
		saveButton.classList.toggle("ppSaved", saved);
		saveButton.setAttribute("aria-label", label);
		saveButton.title = label;
		this._SetSaveButtonText(saveButton, label);
	}

	_SetSaveButtonText(saveButton, text) {
		const label = saveButton.querySelector(".ppSaveButtonText");

		if (label != null) {
			label.textContent = text;
		}
	}

	_IsNativeSaved(quickSave) {
		const button = quickSave.querySelector("button");
		const text = `${button?.getAttribute("aria-label") || ""} ${button?.textContent || ""}`.trim().toLowerCase();
		return /已收藏|saved/.test(text);
	}

	async _ResolveCardOriginal(card) {
		const img = card.querySelector("img[srcset*='pinimg.com'], img[src*='pinimg.com']");
		const video = card.querySelector("video[src*='pinimg.com'], video source[src*='pinimg.com']");
		const pinId = this._GetPinIdFromCard(card);
		const cacheKey = pinId || img?.currentSrc || img?.src || video?.currentSrc || video?.src || "";

		if (this._PinCache.has(cacheKey)) {
			return this._PinCache.get(cacheKey);
		}

		const urls = [];

		if (pinId != null) {
			const pin = await this._FetchPinData(pinId);
			urls.push(...(pin == null ? [] : this._ExtractPinUrls(pin)));
		}

		urls.push(...this._GetVideoSnippetUrls(card));

		if (img != null) {
			urls.push(...this._GetImageUrlCandidates(img));
		}

		if (video != null) {
			urls.push(...this._GetDisplayedVideoUrlCandidates(video));
		}

		const uniqueUrls = this._UniqueUrls(urls);
		const result = {
			url: uniqueUrls[0] || null,
			urls: uniqueUrls,
			filename: this._BuildDownloadName(uniqueUrls[0] || null, pinId),
		};

		if (uniqueUrls.length > 0) {
			this._PinCache.set(cacheKey, result);
		}

		return result;
	}

	async _ResolveDetailOriginal() {
		const pinId = this._GetPinIdFromText(location.href);
		const cacheKey = pinId == null ? `detail:${location.href}` : pinId;

		if (this._PinCache.has(cacheKey)) {
			return this._PinCache.get(cacheKey);
		}

		const urls = [];

		if (pinId != null) {
			const pin = await this._FetchPinData(pinId);
			urls.push(...(pin == null ? [] : this._ExtractPinUrls(pin)));
		}

		urls.push(...this._GetVideoSnippetUrls(document));

		const img = document.querySelector(
			"div[data-test-id='CloseupMainPin'] img[srcset*='pinimg.com']," +
			"div[data-test-id='CloseupMainPin'] img[src*='pinimg.com']," +
			"img[srcset*='pinimg.com']," +
			"img[src*='pinimg.com']"
		);
		const video = document.querySelector(
			"div[data-test-id='CloseupMainPin'] video[src*='pinimg.com']," +
			"div[data-test-id='CloseupMainPin'] video source[src*='pinimg.com']," +
			"video[src*='pinimg.com']," +
			"video source[src*='pinimg.com']"
		);

		if (img != null) {
			urls.push(...this._GetDisplayedImageUrlCandidates(img));
		}

		if (video != null) {
			urls.push(...this._GetDisplayedVideoUrlCandidates(video));
		}

		const uniqueUrls = this._UniqueUrls(urls);
		const result = {
			url: uniqueUrls[0] || null,
			urls: uniqueUrls,
			filename: this._BuildDownloadName(uniqueUrls[0] || null, pinId),
		};

		if (uniqueUrls.length > 0) {
			this._PinCache.set(cacheKey, result);
		}

		return result;
	}

	_GetOriginalFromImage(img) {
		return this._GetImageUrlCandidates(img)[0] || null;
	}

	_GetImageUrlCandidates(img) {
		const srcset = img.getAttribute("srcset") || "";
		const srcsetUrls = srcset
			.split(",")
			.map((item) => item.trim().split(/\s+/)[0])
			.filter(Boolean);

		const largestSrcsetUrls = [...srcsetUrls].reverse();
		const rawUrls = [
			...largestSrcsetUrls,
			img.currentSrc,
			img.src,
			img.getAttribute("src"),
		].filter(Boolean);

		const originalUrls = rawUrls
			.map((candidate) => this._ToOriginalPinimgUrl(candidate))
			.filter(Boolean);

		return this._UniqueUrls([
			...srcsetUrls.filter((url) => url.includes("/originals/")),
			...originalUrls,
			...rawUrls,
		]);
	}

	_GetDisplayedImageUrlCandidates(img) {
		const srcset = img.getAttribute("srcset") || "";
		const srcsetUrls = srcset
			.split(",")
			.map((item) => item.trim().split(/\s+/)[0])
			.filter(Boolean);

		return this._UniqueUrls([
			img.currentSrc,
			img.src,
			img.getAttribute("src"),
			...[...srcsetUrls].reverse(),
		].filter(Boolean));
	}

	_GetDisplayedVideoUrlCandidates(videoOrSource) {
		const video = videoOrSource.closest?.("video") || videoOrSource;
		const sourceUrls = [...(video.querySelectorAll?.("source[src*='pinimg.com']") || [])]
			.map((source) => source.src || source.getAttribute("src"))
			.filter(Boolean);

		return this._UniqueUrls([
			video.currentSrc,
			video.src,
			video.getAttribute?.("src"),
			...sourceUrls,
		].filter(Boolean));
	}

	_GetVideoSnippetUrls(root) {
		const snippets = [
			...(root.matches?.("script[data-test-id='video-snippet']") ? [root] : []),
			...(root.querySelectorAll?.("script[data-test-id='video-snippet']") || []),
		];
		const urls = [];

		for (const snippet of snippets) {
			try {
				const data = JSON.parse(snippet.textContent || "{}");
				const contentUrls = Array.isArray(data.contentUrl) ? data.contentUrl : [data.contentUrl];
				urls.push(...contentUrls.filter((url) => this._IsDownloadableVideoUrl(url)));
			}
			catch (error) {
				console.warn("Cannot parse Pinterest video snippet:", error);
			}
		}

		return this._UniqueUrls(urls);
	}

	_ToOriginalPinimgUrl(url) {
		try {
			const parsed = new URL(url, location.href);

			if (!parsed.hostname.endsWith("pinimg.com")) {
				return null;
			}

			if (parsed.pathname.includes("/originals/")) {
				return parsed.toString();
			}

			if (/\/\d+x\//.test(parsed.pathname)) {
				parsed.pathname = parsed.pathname.replace(/\/\d+x\//, "/originals/");
				return parsed.toString();
			}
		}
		catch (error) {
			console.error("Invalid image url:", url, error);
		}

		return null;
	}

	_GetPinIdFromCard(card) {
		const link = card.querySelector("a[href*='/pin/']");
		return link == null ? null : this._GetPinIdFromText(link.href || link.getAttribute("href"));
	}

	_GetPinIdFromText(text) {
		if (text == null) {
			return null;
		}

		const match = /\/pin\/([\w-]+)\/?/.exec(text) || /\/(\d+)\/?/.exec(text);
		return match == null ? null : match[1];
	}

	async _FetchPinData(id) {
		try {
			const data = encodeURIComponent(JSON.stringify({
				options: {
					id,
					field_set_key: "detailed",
					noCache: true,
				},
				context: {},
			}));
			const sourceUrl = encodeURIComponent(`/pin/${id}/`);
			const url = `${location.origin}/resource/PinResource/get/?source_url=${sourceUrl}&data=${data}&_=${Date.now()}`;

			const res = await fetch(url, {
				method: "GET",
				headers: {
					"X-Pinterest-PWS-Handler": "www/pin/[id].js",
				},
			});

			if (!res.ok) {
				console.error(`Request failed. Status: ${res.status}`);
				return null;
			}

			const json = await res.json();

			if (json?.resource_response?.status !== "success") {
				console.error(json);
				return null;
			}

			return json.resource_response.data;
		}
		catch (error) {
			console.error("PinResource request failed:", error);
			return null;
		}
	}

	_ExtractPinUrls(pin) {
		const urls = [
			...this._ExtractRecursiveVideoUrls(pin),
			...this._ExtractVideoUrls(pin?.videos),
			...this._ExtractVideoUrls(pin?.video),
			...this._ExtractVideoUrls(pin?.image?.video),
		];

		if (pin?.story_pin_data?.pages != null) {
			for (const page of pin.story_pin_data.pages) {
				urls.push(...this._ExtractVideoUrls(page?.video));
				urls.push(...this._ExtractVideoUrls(page?.blocks?.[0]?.video));
				urls.push(...this._ExtractVideoUrls(page?.blocks?.["0"]?.video));
				urls.push(page?.image?.images?.originals?.url);
				urls.push(page?.blocks?.[0]?.image?.images?.originals?.url);
				urls.push(page?.blocks?.["0"]?.image?.images?.originals?.url);
			}
		}

		urls.push(pin?.images?.orig?.url);

		return this._UniqueUrls(urls.filter(Boolean));
	}

	_ExtractRecursiveVideoUrls(value, seen = new WeakSet()) {
		if (value == null) {
			return [];
		}

		if (typeof value === "string") {
			return this._IsDownloadableVideoUrl(value) ? [value] : [];
		}

		if (typeof value !== "object") {
			return [];
		}

		if (seen.has(value)) {
			return [];
		}

		seen.add(value);

		const urls = [];
		urls.push(...this._ExtractVideoUrls(value));

		for (const [key, child] of Object.entries(value)) {
			if (key === "video_list" || key === "videoList") {
				urls.push(...this._ExtractVideoUrls({ video_list: child }));
				continue;
			}

			if (typeof child === "string") {
				if (this._IsDownloadableVideoUrl(child)) {
					urls.push(child);
				}
				continue;
			}

			urls.push(...this._ExtractRecursiveVideoUrls(child, seen));
		}

		const mp4Urls = urls.filter((url) => !/\.m3u8(\?|$)/i.test(url) && !/\/hls\//i.test(url));
		const hlsUrls = urls.filter((url) => /\.m3u8(\?|$)/i.test(url) || /\/hls\//i.test(url));

		return this._UniqueUrls([...mp4Urls, ...hlsUrls]);
	}

	_ExtractVideoUrls(video) {
		if (video == null) {
			return [];
		}

		const directUrls = [
			video.url,
			video.video_url,
			video.videoUrl,
			video.original_url,
			video.originalUrl,
		].filter((url) => this._IsDownloadableVideoUrl(url));
		const isHlsUrl = (url) => /\.m3u8(\?|$)/i.test(url) || /\/hls\//i.test(url);

		const formats = video.video_list || video.videoList;

		if (formats == null) {
			return directUrls;
		}

		const entries = Object.entries(formats)
			.map(([key, value]) => ({
				key,
				url: value?.url,
				width: Number(value?.width || 0),
				height: Number(value?.height || 0),
			}))
			.filter((entry) => entry.url != null);

		const score = (entry) => {
			const keyScore = /1080/.test(entry.key) ? 3 : /720/.test(entry.key) ? 2 : /540|480/.test(entry.key) ? 1 : 0;
			return entry.width * entry.height + keyScore;
		};
		const isHls = (entry) => isHlsUrl(entry.url) || /hls/i.test(entry.key);
		const mp4Urls = entries
			.filter((entry) => !isHls(entry))
			.sort((a, b) => score(b) - score(a))
			.map((entry) => entry.url);
		const hlsUrls = entries
			.filter(isHls)
			.sort((a, b) => score(b) - score(a))
			.map((entry) => entry.url);

		return this._UniqueUrls([
			...directUrls.filter((url) => !isHlsUrl(url)),
			...mp4Urls,
			...directUrls.filter(isHlsUrl),
			...hlsUrls,
		]);
	}

	async _DownloadFirstAvailable(urls, filename) {
		const errors = [];

		for (const url of urls) {
			try {
				await this._DownloadUrlNoOpenFallback(url, this._BuildDownloadName(url, null) || filename);
				return;
			}
			catch (error) {
				console.warn("Download candidate failed:", url, error);
				errors.push(error);
			}
		}

		throw errors[0] || new Error("All download candidates failed.");
	}

	async _DownloadUrlNoOpenFallback(url, filename) {
		try {
			await this._DownloadWithGMDownload(url, filename);
			return;
		}
		catch (error) {
			console.warn("GM_download failed or timed out, trying blob fallback:", error);
		}

		try {
			await this._DownloadWithGMRequest(url, filename);
			return;
		}
		catch (error) {
			console.warn("GM_xmlhttpRequest fallback failed:", error);
		}

		throw new Error(`Cannot download url: ${url}`);
	}

	async _CopyText(text) {
		if (typeof GM_setClipboard === "function") {
			GM_setClipboard(text, "text");
			return;
		}

		if (globalThis.GM != null && typeof GM.setClipboard === "function") {
			await GM.setClipboard(text, "text");
			return;
		}

		if (navigator.clipboard != null && typeof navigator.clipboard.writeText === "function") {
			await navigator.clipboard.writeText(text);
			return;
		}

		throw new Error("Clipboard API is unavailable.");
	}

	async _DownloadWithGMDownload(url, filename) {
		if (typeof GM_download === "function") {
			await this._WithTimeout(new Promise((resolve, reject) => {
				GM_download({
					url,
					name: filename,
					saveAs: false,
					timeout: 30000,
					onload: resolve,
					onerror: reject,
					ontimeout: reject,
				});
			}), 25000, "GM_download timeout");
			return;
		}

		if (globalThis.GM != null && typeof GM.download === "function") {
			await this._WithTimeout(GM.download({
				url,
				name: filename,
				saveAs: false,
			}), 25000, "GM.download timeout");
			return;
		}

		throw new Error("GM_download is unavailable.");
	}

	async _DownloadWithGMRequest(url, filename) {
		const blob = await this._RequestBlob(url);
		const objectUrl = URL.createObjectURL(blob);

		try {
			this._ClickDownloadLink(objectUrl, filename);
		}
		finally {
			setTimeout(() => URL.revokeObjectURL(objectUrl), 30000);
		}
	}

	async _RequestBlob(url) {
		if (typeof GM_xmlhttpRequest === "function") {
			return await this._WithTimeout(new Promise((resolve, reject) => {
				GM_xmlhttpRequest({
					method: "GET",
					url,
					responseType: "blob",
					timeout: 30000,
					onload: (res) => {
						if (res.status >= 200 && res.status < 300 && res.response instanceof Blob) {
							resolve(res.response);
						}
						else {
							reject(new Error(`GM_xmlhttpRequest failed: ${res.status}`));
						}
					},
					onerror: reject,
					ontimeout: reject,
				});
			}), 35000, "GM_xmlhttpRequest timeout");
		}

		if (globalThis.GM != null && typeof GM.xmlHttpRequest === "function") {
			const res = await this._WithTimeout(GM.xmlHttpRequest({
				method: "GET",
				url,
				responseType: "blob",
				timeout: 30000,
			}), 35000, "GM.xmlHttpRequest timeout");

			if (res.status >= 200 && res.status < 300 && res.response instanceof Blob) {
				return res.response;
			}

			throw new Error(`GM.xmlHttpRequest failed: ${res.status}`);
		}

		const res = await this._WithTimeout(fetch(url, { credentials: "omit" }), 30000, "fetch timeout");

		if (!res.ok) {
			throw new Error(`fetch failed: ${res.status}`);
		}

		return await res.blob();
	}

	_WithTimeout(promise, ms, message) {
		return new Promise((resolve, reject) => {
			const timer = setTimeout(() => reject(new Error(message)), ms);
			Promise.resolve(promise).then(
				(value) => {
					clearTimeout(timer);
					resolve(value);
				},
				(error) => {
					clearTimeout(timer);
					reject(error);
				}
			);
		});
	}

	_ClickDownloadLink(url, filename) {
		const a = document.createElement("a");
		a.href = url;
		a.download = filename;
		a.rel = "noopener";
		a.style.display = "none";
		document.body.appendChild(a);
		a.click();
		a.remove();
	}

	_UniqueUrls(urls) {
		const seen = new Set();
		const result = [];

		for (const url of urls) {
			if (url == null || seen.has(url)) {
				continue;
			}

			seen.add(url);
			result.push(url);
		}

		return result;
	}

	_BuildDownloadName(url, pinId) {
		let fileName = "pinterest-original";

		try {
			if (url != null) {
				const parsed = new URL(url, location.href);
				fileName = decodeURIComponent(parsed.pathname.split("/").pop() || fileName);
			}
		}
		catch {
			fileName = "pinterest-original";
		}

		if (!/\.[a-z0-9]{2,5}$/i.test(fileName)) {
			fileName += this._LooksLikeVideoUrl(url) ? ".mp4" : ".jpg";
		}

		return pinId == null ? fileName : `pinterest-${pinId}-${fileName}`;
	}

	_LooksLikeVideoUrl(url) {
		return this._IsDownloadableVideoUrl(url);
	}

	_IsDownloadableVideoUrl(url) {
		if (url == null) {
			return false;
		}

		return /\.(mp4|m4v|webm|mov|m3u8)(\?|$)/i.test(url) || /\/hls\//i.test(url);
	}

}

new PinterestPlus();