您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Every web page is an installable app! Generate or repair a Web Manifest for any web page.
当前为
// ==UserScript== // @name Installability // @description Every web page is an installable app! Generate or repair a Web Manifest for any web page. // @namespace Itsnotlupus Industries // @match https://*/* // @version 1.2 // @noframes // @author itsnotlupus // @license MIT // @require https://gf.qytechs.cn/scripts/468394-itsnotlupus-tiny-utilities/code/utils.js // @grant GM_xmlhttpRequest // @grant GM_addElement // @grant GM_getValue // @grant GM_setValue // ==/UserScript== /*jshint esversion:11 */ const CACHE_MANIFEST_EXPIRATION = 24*3600*1000; // keep cached bits of manifests on any given site for 24 hours before fetching/generating new ones. async function cacheInto(key, work) { const cached = GM_getValue(key); if (cached && cached.expires > Date.now()) return cached.data; const data = await work(); if (data != null) GM_setValue(key, { expires: Date.now() + CACHE_MANIFEST_EXPIRATION, data }); return data; } const resolveURI = (uri, base=location.href) => uri && new URL(uri, base).toString(); /** * load an image without CSP restrictions. */ function getImage(src) {1 return new Promise((resolve) => { const img = GM_addElement('img', { src: resolveURI(src), crossOrigin: "anonymous" }); img.onload = () => resolve(img); img.onerror = () => resolve(null); img.remove(); }); } function grabURL(src) { return new Promise(resolve => { const url = resolveURI(src); GM_xmlhttpRequest({ url, responseType: 'blob', async onload(res) { resolve(res.response); }, onerror() { log("Couldn't grab URL " + s); resolve(null); } }); }); } /** * Grab an image and its mime-type regardless of browser sandbox limitations. */ async function getUntaintedImage(src) { const blob = await grabURL(src); const blobURL = URL.createObjectURL(blob); const img = await getImage(blobURL); if (!img) return null; URL.revokeObjectURL(blobURL); return { src: resolveURI(src), img, width: img.naturalWidth, height: img.naturalHeight, type: blob.type }; } function makeBigPNG(fromImg) { // scale to at least 144x144, but keep the pixels if there are more. const width = Math.max(144, fromImg.width); const height = Math.max(144, fromImg.height); const canvas = crel('canvas', { width, height }); const ctx = canvas.getContext('2d'); ctx.drawImage(fromImg, 0, 0, width, height); const url = canvas.toDataURL({ type: "image/png" }); return { src: url, width, height, type: "image/png" }; } async function repairManifest() { log("Manifest found. Analyzing for problems.."); let fixed = 0; const manifestURL = $`link[rel="manifest"]`.href; const manifest = await cacheInto("site_manifest:" + location.origin, async () => JSON.parse(await (await grabURL(manifestURL)).text())); // fix manifests with missing start_url if (!manifest.start_url) { manifest.start_url = location.origin; fixed++; } // fix manifests with display values Chromium doesn't like anymore if (!["standalone", "fullscreen", "minimal-ui"].includes(manifest.display)) { manifest.display = "minimal-ui"; fixed++; } if (fixed) { // since we're loading the manifest from a data: URI, fix all the relative URIs (TODO: some relative URIs may linger) manifest.icons.forEach(img => img.src= resolveURI(img.src, manifestURL)); ["start_url", "scope"].forEach(k => manifest[k] = resolveURI(manifest[k], manifestURL)); $`link[rel="manifest"]`.remove(); log(`Fixed ${fixed} issue${fixed>1?'s':''} in site manifest.`); verb = 'repaired'; return manifest; } // nothing to do, let the original manifest stand.nothing. verb = 'validated'; return null; } async function generateManifest() { // Remember how there's this universal way to get a web site's name? Yeah, me neither. const goodNames = [ // plausible places to find one $`meta[name="application-name"]`?.content, $`meta[name="apple-mobile-web-app-title"]`?.content, $`meta[name="al:android:app_name"]`?.content, $`meta[name="al:ios:app_name"]`?.content, $`meta[property="og:site_name"]`?.content, $`meta[property="og:title"]`?.content, ].filter(v=>!!v).sort((a,b)=>a.length-b.length); // short names first. const badNames = [ // various bad ideas $`link[rel="search]"`?.title.replace(/ search/i,''), document.title, $`h1`?.textContent, [...location.hostname.replace(/^www\./,'')].map((c,i)=>i?c:c.toUpperCase()).join('') // capitalized domain name. If everything else fails, there's at least this. ].filter(v=>!!v); const short_name = goodNames[0] ?? badNames[0]; const app_name = goodNames.at(-1) ?? badNames[0]; const descriptions = [ $`meta[property="og:description"]`?.content, $`meta[name="description"]`?.content, $`meta[name="description"]`?.getAttribute("value"), $`meta[name="twitter:description"]`?.content, ].filter(v=>!!v); const app_description = descriptions[0]; const colors = [ $`meta[name="theme-color"]`?.content, getComputedStyle(document.body).backgroundColor ].filter(v=>!!v); const theme_color = colors[0]; const background_color = colors.at(-1); // focus on caching only the bits with network requests const images = await cacheInto("images:"+location.origin, async () => { const icons = [ ...Array.from($$`link[rel*="icon"]`).filter(link=>link.rel!="mask-icon").map(link=>link.href), resolveURI($`meta[itemprop="image"]`?.content), ].filter(v=>!!v); // fetch all the icons, so we know what we're working with. const images = (await Promise.all(icons.map(getUntaintedImage))).filter(v=>!!v); images.sort((a,b)=>b.height - a.height); // largest image first. if (!images.length) { const fallback = await getUntaintedImage("/favicon.ico"); // last resort. well known location for tiny site icons. if (fallback) images.unshift(fallback); } if (!images.length) { verb = 'could not be generated because no app icons were found'; return; // just give up. we can't install an app without an icon. } // grab the biggest one. const biggestImage = images[0]; if (biggestImage.width < 144 || biggestImage.height < 144 || biggestImage.type !== 'image/png') { log(`We may not have a valid icon yet, scaling an image of type ${biggestImage.type} and size (${biggestImage.width}x${biggestImage.height}) into a big enough PNG.`); // welp, we're gonna scale it. const img = await makeBigPNG(biggestImage.img); images.unshift(img); } images.forEach(img=>delete img.img); verb = ''; return images; }); if (!images) { return; } verb += 'generated'; // There it is, our glorious Web Manifest. return { name: app_name, short_name: short_name, description: app_description, start_url: location.href, display: "standalone", theme_color: theme_color, background_color: background_color, icons: images.map(img => ({ src: img.src, sizes: `${img.width}x${img.height}`, type: img.type })) }; } let adjective; let verb = 'grabbed from cache and '; async function main() { const start = Date.now(); let manifest; if ($`link[rel="manifest"]`) { adjective = 'Site'; manifest = await repairManifest(); } else { adjective = 'Custom'; manifest = await generateManifest(); } if (manifest) { // Use GM_addElement to inject the manifest. // It doesn't succeed in bypassing Content Security Policy rules today, but maybe userscript extensions will make this work someday. GM_addElement(document.head, 'link', { rel: "manifest", href: 'data:application/manifest+json,'+encodeURIComponent(JSON.stringify(manifest)) }); } log(`${adjective} manifest ${verb} in ${Date.now()-start}ms.`, manifest ? JSON.stringify(manifest,null,2).replace(/"data:.*?"/g,`"data: URI removed"`) : ''); } withLogs(main);
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址