|
5 | 5 | */ |
6 | 6 |
|
7 | 7 | export const BUILTIN_TOOLS = [ |
| 8 | + { |
| 9 | + slug: 'youtube-url-normalize', |
| 10 | + name: 'YouTube 链接规范化', |
| 11 | + category: '视频', |
| 12 | + io: { input: 'text', output: 'text' }, |
| 13 | + desc: '从 YouTube watch、短链接、Shorts、embed 或 live 链接中提取视频 ID,并生成规范链接。', |
| 14 | + sampleIn: 'https://youtu.be/dQw4w9WgXcQ?t=42', |
| 15 | + params: [], |
| 16 | + source: `function transform(input, params, ctx) { |
| 17 | + const raw = String(input || '').trim(); |
| 18 | + if (!raw) throw new Error('请输入 YouTube URL 或 11 位视频 ID'); |
| 19 | + let videoId = ''; |
| 20 | + if (/^[A-Za-z0-9_-]{11}$/.test(raw)) videoId = raw; |
| 21 | + else { |
| 22 | + let url; |
| 23 | + try { url = new URL(raw); } catch { throw new Error('无效的 YouTube URL'); } |
| 24 | + const host = url.hostname.toLowerCase().replace(/^www\\./, ''); |
| 25 | + if (host === 'youtu.be') videoId = url.pathname.split('/').filter(Boolean)[0] || ''; |
| 26 | + else if (host === 'youtube.com' || host === 'm.youtube.com') { |
| 27 | + if (url.pathname === '/watch') videoId = url.searchParams.get('v') || ''; |
| 28 | + else { |
| 29 | + const parts = url.pathname.split('/').filter(Boolean); |
| 30 | + if (['shorts', 'embed', 'live', 'v'].includes(parts[0])) videoId = parts[1] || ''; |
| 31 | + } |
| 32 | + } else throw new Error('仅支持 youtube.com、m.youtube.com 和 youtu.be'); |
| 33 | + } |
| 34 | + if (!/^[A-Za-z0-9_-]{11}$/.test(videoId)) throw new Error('无法提取有效的 YouTube 视频 ID'); |
| 35 | + return JSON.stringify({ schemaVersion: 'toolkit.youtube-state.v1', videoId, |
| 36 | + canonicalUrl: 'https://www.youtube.com/watch?v=' + videoId }); |
| 37 | +}`, |
| 38 | + }, |
| 39 | + |
| 40 | + { |
| 41 | + slug: 'youtube-oembed-read', |
| 42 | + name: 'YouTube 公开信息获取', |
| 43 | + category: '视频', |
| 44 | + io: { input: 'text', output: 'text' }, |
| 45 | + desc: '通过 YouTube oEmbed 获取公开标题、作者、频道、缩略图和嵌入信息,无需 API Key。', |
| 46 | + sampleIn: '{"schemaVersion":"toolkit.youtube-state.v1","videoId":"dQw4w9WgXcQ","canonicalUrl":"https://www.youtube.com/watch?v=dQw4w9WgXcQ"}', |
| 47 | + params: [], |
| 48 | + sandbox: { net: true, netHosts: ['www.youtube.com'], timeoutMs: 10000 }, |
| 49 | + flags: ['net'], |
| 50 | + source: `async function transform(input, params, ctx) { |
| 51 | + let state; |
| 52 | + try { state = JSON.parse(String(input || '')); } catch { throw new Error('需要先执行 YouTube 链接规范化'); } |
| 53 | + if (state.schemaVersion !== 'toolkit.youtube-state.v1' || !state.videoId) throw new Error('无效的 YouTube workflow 状态'); |
| 54 | + const endpoint = 'https://www.youtube.com/oembed?format=json&url=' + encodeURIComponent(state.canonicalUrl); |
| 55 | + const data = await ctx.http(endpoint, { method: 'GET', timeoutMs: 8000, maxResponseBytes: 262144 }); |
| 56 | + if (!data || typeof data !== 'object') throw new Error('YouTube oEmbed 返回格式无效'); |
| 57 | + return JSON.stringify({ ...state, metadata: { source: 'youtube-oembed', |
| 58 | + title: String(data.title || ''), authorName: String(data.author_name || ''), |
| 59 | + authorUrl: String(data.author_url || ''), thumbnailUrl: String(data.thumbnail_url || ''), |
| 60 | + thumbnailWidth: Number(data.thumbnail_width || 0), thumbnailHeight: Number(data.thumbnail_height || 0), |
| 61 | + embedHtml: String(data.html || '') } }); |
| 62 | +}`, |
| 63 | + }, |
| 64 | + |
| 65 | + { |
| 66 | + slug: 'youtube-info-format', |
| 67 | + name: 'YouTube 信息格式化', |
| 68 | + category: '视频', |
| 69 | + io: { input: 'text', output: 'key-value' }, |
| 70 | + desc: '将 YouTube workflow 状态格式化为可读文本和键值视图;不可用字段会被省略。', |
| 71 | + sampleIn: '{"schemaVersion":"toolkit.youtube-state.v1","videoId":"dQw4w9WgXcQ","canonicalUrl":"https://www.youtube.com/watch?v=dQw4w9WgXcQ","metadata":{"title":"Example"}}', |
| 72 | + params: [], |
| 73 | + source: `function transform(input, params, ctx) { |
| 74 | + let state; |
| 75 | + try { state = JSON.parse(String(input || '')); } catch { throw new Error('需要 JSON workflow 状态'); } |
| 76 | + const m = state.metadata || {}; |
| 77 | + const rows = [['标题', String(m.title || '')], ['作者', String(m.authorName || '')], |
| 78 | + ['频道主页', String(m.authorUrl || '')], ['视频 ID', String(state.videoId || '')], |
| 79 | + ['规范链接', String(state.canonicalUrl || '')], ['缩略图', String(m.thumbnailUrl || '')], |
| 80 | + ['嵌入代码', String(m.embedHtml || '')]].filter((row) => row[1] !== ''); |
| 81 | + return { text: rows.map((row) => row[0] + ': ' + row[1]).join('\\n'), view: { kind: 'kv', rows } }; |
| 82 | +}`, |
| 83 | + }, |
| 84 | + |
8 | 85 | { |
9 | 86 | slug: 'json-pretty', |
10 | 87 | name: 'JSON 美化 / 排序', |
@@ -690,6 +767,224 @@ export const BUILTIN_TOOLS = [ |
690 | 767 | if (hit[0] === '') re.lastIndex++; |
691 | 768 | } |
692 | 769 | return count ? '共 ' + count + ' 处匹配\\n' + out.join('\\n') : '无匹配'; |
| 770 | +}`, |
| 771 | + }, |
| 772 | + |
| 773 | + { |
| 774 | + slug: 'image-data-url-inspect', |
| 775 | + name: '图片信息读取', |
| 776 | + category: '图像', |
| 777 | + io: { input: 'image', output: 'image' }, |
| 778 | + desc: '读取上传或粘贴的图片(base64 data URL),纯 JS 解析 PNG/GIF/JPEG 的宽高与字节数,并把原图回传以便预览和下载。', |
| 779 | + sampleIn: '', |
| 780 | + params: [], |
| 781 | + source: `function transform(input, params, ctx) { |
| 782 | + const raw = String(input || '').trim(); |
| 783 | + const m = raw.match(/^data:([\\w.+-]+\\/[\\w.+-]+);base64,(.*)$/); |
| 784 | + if (!m) throw new Error('请输入图片 data URL(data:image/...;base64,...),可直接粘贴、拖入或上传图片'); |
| 785 | + const mediaType = m[1].toLowerCase(); |
| 786 | + if (mediaType.indexOf('image/') !== 0) throw new Error('仅支持图片输入(image/*)'); |
| 787 | + const b64 = m[2].replace(/\\s+/g, ''); |
| 788 | + const bin = atob(b64); |
| 789 | + const bytes = new Uint8Array(bin.length); |
| 790 | + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); |
| 791 | + const dv = new DataView(bytes.buffer); |
| 792 | + let w = 0, h = 0, kind = ''; |
| 793 | + if (bytes[0] === 0x89 && bytes[1] === 0x50) { |
| 794 | + kind = 'PNG'; w = dv.getUint32(16); h = dv.getUint32(20); |
| 795 | + } else if (bin.slice(0, 6) === 'GIF87a' || bin.slice(0, 6) === 'GIF89a') { |
| 796 | + kind = 'GIF'; w = dv.getUint16(6, true); h = dv.getUint16(8, true); |
| 797 | + } else if (bytes[0] === 0xFF && bytes[1] === 0xD8) { |
| 798 | + kind = 'JPEG'; |
| 799 | + let i = 2; |
| 800 | + while (i + 4 < bytes.length) { |
| 801 | + if (bytes[i] !== 0xFF) { i++; continue; } |
| 802 | + const marker = bytes[i + 1]; |
| 803 | + if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) { |
| 804 | + h = dv.getUint16(i + 5); w = dv.getUint16(i + 7); break; |
| 805 | + } |
| 806 | + i += 2 + dv.getUint16(i + 2); |
| 807 | + } |
| 808 | + } else { |
| 809 | + kind = (mediaType.split('/')[1] || 'image').toUpperCase(); |
| 810 | + } |
| 811 | + const ext = kind === 'JPEG' ? 'jpg' : kind.toLowerCase(); |
| 812 | + const text = '图片信息' + '\\n' + [ |
| 813 | + '类型: ' + kind + ' (' + mediaType + ')', |
| 814 | + '尺寸: ' + (w ? (w + ' × ' + h + ' px') : '未知(该格式未解析尺寸)'), |
| 815 | + '大小: ' + bytes.length + ' bytes', |
| 816 | + ].join('\\n'); |
| 817 | + return { |
| 818 | + text: text, |
| 819 | + files: [{ name: 'image.' + ext, mediaType: mediaType, dataUrl: raw }], |
| 820 | + }; |
| 821 | +}`, |
| 822 | + }, |
| 823 | + |
| 824 | + { |
| 825 | + slug: 'qrcode-generate', |
| 826 | + name: '二维码生成', |
| 827 | + category: '图像', |
| 828 | + io: { input: 'text', output: 'image' }, |
| 829 | + desc: '把文字或链接生成可扫描的二维码(自动选择 v1–v10 版本、字节模式、L 容错),输出为 SVG 图片,可直接预览或下载。纯 JS 实现,无外部依赖。', |
| 830 | + sampleIn: 'https://toolkit.fun', |
| 831 | + params: [], |
| 832 | + source: `function transform(input, params, ctx) { |
| 833 | + // QR Code 版本 1-10、字节模式、纠错级 L(表格按版本索引,0 => v1) |
| 834 | + var BLOCKS = [1, 1, 1, 1, 1, 2, 2, 2, 2, 4]; // EC 分块数 |
| 835 | + var ECPER = [7, 10, 15, 20, 26, 18, 20, 24, 30, 18]; // 每块 EC 码字数 |
| 836 | + var DATA = [19, 34, 55, 80, 108, 136, 156, 194, 232, 274]; // 数据码字数 |
| 837 | + var CAP = [17, 32, 53, 78, 106, 134, 154, 192, 230, 271]; // 最大字节负载 |
| 838 | + var ALIGN = [[], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34], [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50]]; |
| 839 | + var VINFO = { 7: 0x07C94, 8: 0x085BC, 9: 0x09A99, 10: 0x0A4D3 }; |
| 840 | +
|
| 841 | + function build(text) { |
| 842 | + var bytes = new TextEncoder().encode(text); |
| 843 | + var version = 0; |
| 844 | + for (var v = 0; v < 10; v++) { if (bytes.length <= CAP[v]) { version = v + 1; break; } } |
| 845 | + if (!version) throw new Error('too_long'); |
| 846 | + var vi = version - 1; |
| 847 | + var N = 17 + 4 * version; |
| 848 | + var dataTotal = DATA[vi], ecPer = ECPER[vi], nBlocks = BLOCKS[vi]; |
| 849 | + var ccBits = version >= 10 ? 16 : 8; |
| 850 | +
|
| 851 | + var M = [], R = []; |
| 852 | + for (var i = 0; i < N; i++) { M.push(new Array(N).fill(0)); R.push(new Array(N).fill(false)); } |
| 853 | + function inB(r, c) { return r >= 0 && r < N && c >= 0 && c < N; } |
| 854 | + function putF(r, c, val) { if (inB(r, c)) { M[r][c] = val ? 1 : 0; R[r][c] = true; } } |
| 855 | + function resv(r, c) { if (inB(r, c)) R[r][c] = true; } |
| 856 | +
|
| 857 | + function finder(r0, c0) { |
| 858 | + for (var dr = -1; dr <= 7; dr++) for (var dc = -1; dc <= 7; dc++) { |
| 859 | + var r = r0 + dr, c = c0 + dc; if (!inB(r, c)) continue; |
| 860 | + var val = 0; |
| 861 | + if (dr >= 0 && dr <= 6 && (dc === 0 || dc === 6)) val = 1; |
| 862 | + else if (dc >= 0 && dc <= 6 && (dr === 0 || dr === 6)) val = 1; |
| 863 | + else if (dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4) val = 1; |
| 864 | + putF(r, c, val); |
| 865 | + } |
| 866 | + } |
| 867 | + finder(0, 0); finder(0, N - 7); finder(N - 7, 0); |
| 868 | + for (var t = 8; t < N - 8; t++) { var tv = (t % 2 === 0) ? 1 : 0; putF(t, 6, tv); putF(6, t, tv); } |
| 869 | +
|
| 870 | + var ac = ALIGN[vi]; |
| 871 | + for (var ai = 0; ai < ac.length; ai++) for (var aj = 0; aj < ac.length; aj++) { |
| 872 | + if ((ai === 0 && aj === 0) || (ai === 0 && aj === ac.length - 1) || (ai === ac.length - 1 && aj === 0)) continue; |
| 873 | + var ar = ac[ai], acx = ac[aj]; |
| 874 | + for (var dr = -2; dr <= 2; dr++) for (var dc = -2; dc <= 2; dc++) { |
| 875 | + var dark = (dr === -2 || dr === 2 || dc === -2 || dc === 2 || (dr === 0 && dc === 0)) ? 1 : 0; |
| 876 | + putF(ar + dr, acx + dc, dark); |
| 877 | + } |
| 878 | + } |
| 879 | + putF(N - 8, 8, 1); |
| 880 | +
|
| 881 | + for (var fi = 0; fi < 15; fi++) { |
| 882 | + if (fi < 6) resv(fi, 8); else if (fi < 8) resv(fi + 1, 8); else resv(N - 15 + fi, 8); |
| 883 | + if (fi < 8) resv(8, N - fi - 1); else if (fi < 9) resv(8, 15 - fi - 1 + 1); else resv(8, 15 - fi - 1); |
| 884 | + } |
| 885 | + var vb = null; |
| 886 | + if (version >= 7) { |
| 887 | + vb = VINFO[version]; |
| 888 | + for (var k = 0; k < 18; k++) { var vr = Math.floor(k / 3), vcc = k % 3 + N - 11; resv(vr, vcc); resv(vcc, vr); } |
| 889 | + } |
| 890 | +
|
| 891 | + var bits = []; |
| 892 | + function push(val, n) { for (var k = n - 1; k >= 0; k--) bits.push((val >>> k) & 1); } |
| 893 | + push(0x4, 4); // 字节模式 |
| 894 | + push(bytes.length, ccBits); // 字符计数 |
| 895 | + for (var bi = 0; bi < bytes.length; bi++) push(bytes[bi], 8); |
| 896 | + var term = Math.min(4, dataTotal * 8 - bits.length); |
| 897 | + for (var te = 0; te < term; te++) bits.push(0); |
| 898 | + while (bits.length % 8 !== 0) bits.push(0); |
| 899 | + var dataCw = []; |
| 900 | + for (var b = 0; b + 8 <= bits.length; b += 8) { var by = 0; for (var k2 = 0; k2 < 8; k2++) by = (by << 1) | bits[b + k2]; dataCw.push(by); } |
| 901 | + var pads = [0xEC, 0x11], pi = 0; |
| 902 | + while (dataCw.length < dataTotal) dataCw.push(pads[pi++ % 2]); |
| 903 | + dataCw = dataCw.slice(0, dataTotal); |
| 904 | +
|
| 905 | + var EXP = new Uint8Array(512), LOG = new Uint8Array(256), xx = 1; |
| 906 | + for (var e = 0; e < 255; e++) { EXP[e] = xx; LOG[xx] = e; xx <<= 1; if (xx & 0x100) xx ^= 0x11D; } |
| 907 | + for (e = 255; e < 512; e++) EXP[e] = EXP[e - 255]; |
| 908 | + function gmul(a, b) { if (!a || !b) return 0; return EXP[LOG[a] + LOG[b]]; } |
| 909 | + function polyMul(p1, p2) { var o = new Array(p1.length + p2.length - 1).fill(0); |
| 910 | + for (var x = 0; x < p1.length; x++) for (var y = 0; y < p2.length; y++) o[x + y] ^= gmul(p1[x], p2[y]); return o; } |
| 911 | + function rsEc(darr, deg) { |
| 912 | + var gen = [1]; |
| 913 | + for (var gi = 0; gi < deg; gi++) gen = polyMul(gen, [1, EXP[gi]]); |
| 914 | + var rem = darr.slice().concat(new Array(deg).fill(0)); |
| 915 | + while (rem.length - gen.length >= 0) { |
| 916 | + var cf = rem[0]; |
| 917 | + for (var gj = 0; gj < gen.length; gj++) rem[gj] ^= gmul(gen[gj], cf); |
| 918 | + var go = 0; while (go < rem.length && rem[go] === 0) go++; rem = rem.slice(go); |
| 919 | + } |
| 920 | + while (rem.length < deg) rem.unshift(0); |
| 921 | + return rem.slice(-deg); |
| 922 | + } |
| 923 | +
|
| 924 | + var base = Math.floor(dataTotal / nBlocks), remN = dataTotal - base * nBlocks; |
| 925 | + var blocks = [], idx = 0; |
| 926 | + for (var bl = 0; bl < nBlocks; bl++) { |
| 927 | + var dlen = base + (bl >= nBlocks - remN ? 1 : 0); |
| 928 | + var darr = dataCw.slice(idx, idx + dlen); idx += dlen; |
| 929 | + blocks.push({ d: darr, e: rsEc(darr, ecPer) }); |
| 930 | + } |
| 931 | + var cw = [], maxD = 0; |
| 932 | + for (bl = 0; bl < nBlocks; bl++) if (blocks[bl].d.length > maxD) maxD = blocks[bl].d.length; |
| 933 | + for (var ii = 0; ii < maxD; ii++) for (bl = 0; bl < nBlocks; bl++) if (ii < blocks[bl].d.length) cw.push(blocks[bl].d[ii]); |
| 934 | + for (ii = 0; ii < ecPer; ii++) for (bl = 0; bl < nBlocks; bl++) cw.push(blocks[bl].e[ii]); |
| 935 | +
|
| 936 | + var bitIndex = 7, byteIndex = 0, inc = -1, row = N - 1; |
| 937 | + for (var col = N - 1; col > 0; col -= 2) { |
| 938 | + if (col === 6) col--; |
| 939 | + while (true) { |
| 940 | + for (var c = 0; c < 2; c++) { |
| 941 | + var cc = col - c; |
| 942 | + if (!R[row][cc]) { |
| 943 | + var dark = false; |
| 944 | + if (byteIndex < cw.length) dark = (((cw[byteIndex] >>> bitIndex) & 1) === 1); |
| 945 | + if (((row + cc) & 1) === 0) dark = !dark; // 掩码图案 0 |
| 946 | + M[row][cc] = dark ? 1 : 0; |
| 947 | + bitIndex--; if (bitIndex === -1) { byteIndex++; bitIndex = 7; } |
| 948 | + } |
| 949 | + } |
| 950 | + row += inc; |
| 951 | + if (row < 0 || N <= row) { row -= inc; inc = -inc; break; } |
| 952 | + } |
| 953 | + } |
| 954 | + var FMT = 0x77C4; // ECC-L + 掩码0 的格式信息(所有版本相同) |
| 955 | + for (var fi2 = 0; fi2 < 15; fi2++) { |
| 956 | + var fm = ((FMT >>> fi2) & 1) === 1 ? 1 : 0; |
| 957 | + if (fi2 < 6) M[fi2][8] = fm; else if (fi2 < 8) M[fi2 + 1][8] = fm; else M[N - 15 + fi2][8] = fm; |
| 958 | + if (fi2 < 8) M[8][N - fi2 - 1] = fm; else if (fi2 < 9) M[8][15 - fi2 - 1 + 1] = fm; else M[8][15 - fi2 - 1] = fm; |
| 959 | + } |
| 960 | + M[N - 8][8] = 1; |
| 961 | + if (vb !== null) { |
| 962 | + for (k = 0; k < 18; k++) { |
| 963 | + var vbit = ((vb >>> k) & 1) === 1 ? 1 : 0; |
| 964 | + M[Math.floor(k / 3)][k % 3 + N - 11] = vbit; |
| 965 | + M[k % 3 + N - 11][Math.floor(k / 3)] = vbit; |
| 966 | + } |
| 967 | + } |
| 968 | + return M; |
| 969 | + } |
| 970 | +
|
| 971 | + function toSvg(M) { |
| 972 | + var N = M.length, Q = 6, QUIET = 4, dim = (N + QUIET * 2) * Q; |
| 973 | + var s = '<svg xmlns="http://www.w3.org/2000/svg" width="' + dim + '" height="' + dim + '" viewBox="0 0 ' + dim + ' ' + dim + '">'; |
| 974 | + s += '<rect width="' + dim + '" height="' + dim + '" fill="#ffffff"/>'; |
| 975 | + for (var r = 0; r < N; r++) for (var c = 0; c < N; c++) { |
| 976 | + if (M[r][c]) { var x = (c + QUIET) * Q, y = (r + QUIET) * Q; s += '<rect x="' + x + '" y="' + y + '" width="' + Q + '" height="' + Q + '" fill="#000000"/>'; } |
| 977 | + } |
| 978 | + s += '</svg>'; return s; |
| 979 | + } |
| 980 | +
|
| 981 | + var text = String((params && params.text != null) ? params.text : (input == null ? '' : input)); |
| 982 | + text = text.replace(/^\\s+|\\s+$/g, ''); |
| 983 | + if (!text) return '请输入要生成二维码的内容(文字或链接)'; |
| 984 | + var M; |
| 985 | + try { M = build(text); } |
| 986 | + catch (e) { if (e && e.message === 'too_long') return '内容太长:最多约 270 个英文 / 90 个汉字,请缩短后再试'; throw e; } |
| 987 | + return { files: [{ name: 'qrcode.svg', mediaType: 'image/svg+xml', dataUrl: 'data:image/svg+xml;base64,' + btoa(toSvg(M)) }] }; |
693 | 988 | }`, |
694 | 989 | }, |
695 | 990 | ]; |
0 commit comments