#!/usr/bin/env node /* based on https://github.com/Bloggify/google-font-downloader with deepseek fixes */ "use strict"; const Tilda = require("tilda") , WritableStream = require("streamp").writable , tinyreq = require("tinyreq") , matchAll = require("match-all") , path = require("path") , crypto = require("crypto") ; const USER_AGENT = "User-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36" new Tilda(`${__dirname}/package.json`, { args: [ { name: "url" , desc: "The Google APIs url." , required: true } ], examples: [ "google-font-downloader https://fonts.googleapis.com/css?family=Open+Sans:400,400i,700,700i", "google-font-downloader https://fonts.googleapis.com/css?family=Roboto:300,400,500 --debug" ] }) .option([ { opts: ["debug"] , desc: "Save original downloaded CSS for debugging." , name: "debug" , type: Boolean , default: false }, { opts: ["directory", "d"], desc: "Directory where files are stored", name: "directory", default: "./fonts", }, { opts: ["timestamp", "t"], desc: "Add a timestamp to the stylesheet file (default: 1)", name: "timestamp", default: 1, }, { opts: ["scss", "s"], desc: "Use a scss-extension for the stylesheet for inclusion in a scss-project (default: 0)", name: "scss", default: 0, } ]) .main(action => { const url = action.args.url; const debug = action.options.debug.value; const data = {}; const font_directory = action.options.directory.value; const timestamp = action.options.timestamp.value; const scss = action.options.scss.value; console.log(`Getting the external CSS: ${url}`); if (debug) { console.log("Debug mode: ON - original CSS will be saved") } // Функция для определения набора символов на основе комментария и unicode-range function determineCharSet(comment, unicodeRange) { // Сначала проверяем комментарий const commentLower = comment.toLowerCase(); if (commentLower.includes('vietnamese')) return 'vietnamese'; if (commentLower.includes('cyrillic-ext')) return 'cyrillic-ext'; if (commentLower.includes('cyrillic')) return 'cyrillic'; if (commentLower.includes('greek-ext')) return 'greek-ext'; if (commentLower.includes('greek')) return 'greek'; if (commentLower.includes('latin-ext')) return 'latin-ext'; if (commentLower.includes('latin')) return 'latin'; if (commentLower.includes('arabic')) return 'arabic'; if (commentLower.includes('hebrew')) return 'hebrew'; if (commentLower.includes('thai')) return 'thai'; if (commentLower.includes('devanagari')) return 'devanagari'; if (commentLower.includes('bengali')) return 'bengali'; if (commentLower.includes('tamil')) return 'tamil'; if (commentLower.includes('telugu')) return 'telugu'; if (commentLower.includes('kannada')) return 'kannada'; if (commentLower.includes('malayalam')) return 'malayalam'; if (commentLower.includes('gujarati')) return 'gujarati'; if (commentLower.includes('oriya')) return 'oriya'; if (commentLower.includes('gurmukhi')) return 'gurmukhi'; // Если в комментарии нет информации, анализируем unicode-range if (unicodeRange) { if (unicodeRange.includes('U+0102-0103') || unicodeRange.includes('U+1EA0-1EF9')) return 'vietnamese'; if (unicodeRange.includes('U+0460-052F') || unicodeRange.includes('U+20B4') || unicodeRange.includes('U+2DE0-2DFF') || unicodeRange.includes('U+A640-A69F')) return 'cyrillic-ext'; if (unicodeRange.includes('U+0400-04FF') || unicodeRange.includes('U+0500-052F')) return 'cyrillic'; if (unicodeRange.includes('U+1F00-1FFF')) return 'greek-ext'; if (unicodeRange.includes('U+0370-03FF')) return 'greek'; if (unicodeRange.includes('U+0100-024F') || unicodeRange.includes('U+0259') || unicodeRange.includes('U+1E00-1EFF') || unicodeRange.includes('U+2020') || unicodeRange.includes('U+20A0-20AB') || unicodeRange.includes('U+20AD-20CF') || unicodeRange.includes('U+2113') || unicodeRange.includes('U+2C60-2C7F') || unicodeRange.includes('U+A720-A7FF')) return 'latin-ext'; if (unicodeRange.includes('U+0000-00FF') || unicodeRange.includes('U+0131') || unicodeRange.includes('U+0152-0153') || unicodeRange.includes('U+02BB-02BC') || unicodeRange.includes('U+02C6') || unicodeRange.includes('U+02DA') || unicodeRange.includes('U+02DC') || unicodeRange.includes('U+2000-206F') || unicodeRange.includes('U+2074') || unicodeRange.includes('U+20AC') || unicodeRange.includes('U+2122') || unicodeRange.includes('U+2191') || unicodeRange.includes('U+2193') || unicodeRange.includes('U+2212') || unicodeRange.includes('U+2215') || unicodeRange.includes('U+FEFF') || unicodeRange.includes('U+FFFD')) return 'latin'; if (unicodeRange.includes('U+0600-06FF') || unicodeRange.includes('U+0750-077F') || unicodeRange.includes('U+08A0-08FF') || unicodeRange.includes('U+FB50-FDFF') || unicodeRange.includes('U+FE70-FEFF') || unicodeRange.includes('U+1EE00-1EEFF')) return 'arabic'; if (unicodeRange.includes('U+0590-05FF') || unicodeRange.includes('U+FB1D-FB4F')) return 'hebrew'; if (unicodeRange.includes('U+0E00-0E7F')) return 'thai'; } return 'latin'; // fallback } // Функция для добавления src-original в CSS function addSrcOriginal(css, fontUrl, localPath) { // Ищем блок @font-face, который содержит этот URL const fontFaceRegex = new RegExp(`(@font-face\\s*\\{[^}]*?url\\([^)]*?${fontUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^)]*?\\)[^}]*?\\})`, 'g'); return css.replace(fontFaceRegex, (fontFaceBlock) => { // Добавляем src-original после src return fontFaceBlock.replace(/(src:[^;]+;)/, `$1\n src-original: url(${fontUrl});`); }); } tinyreq({ url, headers: { "user-agent": USER_AGENT } }).then(body => { const matchFontFilesRegex = /url\((https\:\/\/fonts\.gstatic\.com\/.*)\) format/gm // Регулярное выражение для извлечения информации о шрифте из CSS const fontBlockRegex = /\/\*\s*(.*?)\s*\*\/\s*@font-face\s*\{([^}]+)\}/g const fontFamilyRegex = /font-family:\s*['"]?(.*?)['"]?;/i const fontStyleRegex = /font-style:\s*(\w+);/i const fontWeightRegex = /font-weight:\s*(\d+);/i const unicodeRangeRegex = /unicode-range:\s*([^;]+);/i data.original_stylesheet = body data.local_stylesheet = body data.font_urls = matchAll(body, matchFontFilesRegex).toArray() // Сохраняем оригинальный CSS если включен debug режим if (debug) { const originalCssFileName = `google-fonts-original-${Date.now()}.css` const originalCssStream = new WritableStream(originalCssFileName) console.log(`Debug: Saving original CSS to ${originalCssFileName}`) originalCssStream.end(data.original_stylesheet) } // Создаем массив для хранения информации о шрифтах data.fonts = [] // Извлекаем информацию о каждом шрифте из CSS блоков @font-face let fontBlockMatch let fontUrlIndex = 0 while ((fontBlockMatch = fontBlockRegex.exec(body)) !== null) { if (fontUrlIndex >= data.font_urls.length) break; const comment = fontBlockMatch[1] || "" const fontFaceContent = fontBlockMatch[2] const familyMatch = fontFaceContent.match(fontFamilyRegex) const styleMatch = fontFaceContent.match(fontStyleRegex) const weightMatch = fontFaceContent.match(fontWeightRegex) const unicodeMatch = fontFaceContent.match(unicodeRangeRegex) if (familyMatch && data.font_urls[fontUrlIndex]) { const fontFamily = familyMatch[1].replace(/\s+/g, '_').toLowerCase() const fontStyle = (styleMatch && styleMatch[1]) ? styleMatch[1] : 'normal' const fontWeight = (weightMatch && weightMatch[1]) ? weightMatch[1] : '400' const unicodeRange = unicodeMatch ? unicodeMatch[1] : null // Определяем набор символов используя улучшенную функцию const charSet = determineCharSet(comment, unicodeRange) // Формируем понятное имя файла в новом формате const fileExtension = path.extname(data.font_urls[fontUrlIndex].split('?')[0]) || '.woff2' const fileName = `${fontFamily}.${fontWeight}.${charSet}.${fontStyle}${fileExtension}` const localPath = `fonts/${fileName}` data.fonts.push({ remote: data.font_urls[fontUrlIndex], local: localPath, family: fontFamily, weight: fontWeight, style: fontStyle, charSet: charSet, comment: comment, unicodeRange: unicodeRange }) if (debug) { console.log(`Debug: Font ${fontUrlIndex + 1} - Comment: "${comment}", CharSet: ${charSet}`) } fontUrlIndex++ } } // Если не удалось извлечь информацию через CSS блоки, используем альтернативный метод if (data.fonts.length === 0) { console.log("Using alternative font naming method...") data.fonts = data.font_urls.map((url, index) => { // Пытаемся извлесть информацию из URL const urlParts = url.split('/') const fontFileName = urlParts[urlParts.length - 1].split('?')[0] // Разбираем имя файла на компоненты const nameParts = fontFileName.replace('.woff2', '').split('-') let fontFamily = 'font' let fontWeight = '400' let fontStyle = 'normal' let charSet = 'latin' if (nameParts.length >= 2) { fontFamily = nameParts[0] const styleWeight = nameParts[1] // Пытаемся определить вес и стиль if (styleWeight.includes('italic')) { fontStyle = 'italic' fontWeight = styleWeight.replace('italic', '') || '400' } else { fontWeight = styleWeight } // Пытаемся определить набор символов из имени файла if (nameParts.some(part => part.includes('vietnamese'))) charSet = 'vietnamese' else if (nameParts.some(part => part.includes('cyrillic'))) charSet = 'cyrillic' else if (nameParts.some(part => part.includes('greek'))) charSet = 'greek' else if (nameParts.some(part => part.includes('latin'))) charSet = 'latin' } const fileExtension = path.extname(url.split('?')[0]) || '.woff2' const fileName = `${fontFamily}.${fontWeight}.${charSet}.${fontStyle}${fileExtension}` const localPath = `${font_directory}/${fileName}` return { remote: url, local: localPath, family: fontFamily, weight: fontWeight, style: fontStyle, charSet: charSet } }) } console.log(`Detected ${data.fonts.length} font files to download.`) // Проверяем уникальность имен файлов const fileNames = new Set() const duplicates = [] data.fonts.forEach(font => { if (fileNames.has(font.local)) { duplicates.push(font.local) } fileNames.add(font.local) }) if (duplicates.length > 0) { console.log(`Warning: Found ${duplicates.length} duplicate file names. Adding unique identifiers.`) // Добавляем суффиксы к дублирующимся файлам const nameCount = {} data.fonts.forEach(font => { if (nameCount[font.local]) { nameCount[font.local]++ const newLocal = font.local.replace(/\.(woff2|woff|ttf)$/, `.${nameCount[font.local]}.$1`) data.local_stylesheet = data.local_stylesheet.replace(font.local, newLocal) font.local = newLocal } else { nameCount[font.local] = 1 } }) } return Promise.all(data.fonts.map(c => { // Сначала добавляем src-original data.local_stylesheet = addSrcOriginal(data.local_stylesheet, c.remote, c.local) // Затем заменяем URL на локальный путь data.local_stylesheet = data.local_stylesheet.replace(c.remote, c.local) return new Promise(res => { const req = tinyreq({ url: c.remote, encoding: null, headers: { "user-agent": USER_AGENT } }) , stream = new WritableStream(c.local) req.on("data", data => { stream.write(data) }).on("error", e => { console.error("Failed to download " + c.remote) console.error(e) res() }).on("end", () => { console.log(`Downloaded ${c.remote} as ${c.local}`) stream.end() res() }) }) })) }).then(() => { // const ts = timestamp ? `-${Date.now()}` : ''; const fileName = `google-fonts${ timestamp ? `-${Date.now()}` : '' }.css` , cssStream = new WritableStream(fileName) console.log(`Writting the CSS into ${fileName}`) cssStream.end(data.local_stylesheet) if (debug) { console.log("Debug: Process completed. Original CSS and modified CSS have been saved.") } }).catch(error => { console.error("Error during font download process:", error) if (debug) { console.log("Debug: Error occurred. Check the original CSS file for debugging.") } }) });