Часть 1. Challenge
Читая ленту на oDesk, наткнулся на интересный проект по моему направлению (а я отслеживаю, в основном, задачи на написать что-то, прикрутить что-то или иным способом замучить Google Apps Script или приложения Google Apps). Клиент просил написать скрипт, который будет отсылать ему выделенный фрагмент из Google Spreadsheet по нажатию кнопки. Но была в описании одна фраза, зацепившая меня — «Мне сказали, что невозможно создать скрипт, который будет печатать из Google Apps». Я всегда очень любил и люблю «невозможные» задачи:
— Мы сами знаем, что она не имеет решения, — сказал Хунта, немедленно ощетиниваясь. — Мы хотим знать, как её решать.
Аркадий и Борис Стругацкие. Понедельник начинается в субботу
Статья рассчитана на читателей, уже знакомых с Google Apps Script и сопутствующими технологиями.
Часть 2. Мучения
Решение изначально было очевидно — воспользоваться сервисом Google Cloud Print, а печатный документ передавать в форме PDF. Изучение API показало, что необходимо изначально аутентифицироваться в сервисе, затем — послать запрос на печать. Итак, я настроил сервис, настроил принтеры и начал дергать API. Все работает и печатается (из REST клиента)! Пора писать скрипт…
Аутентификация
… и сразу со всего размаха налетаю на первый подводный камень: аутентификация. Google Cloud Print не хватает простого логина, у него есть собственный authentication scope. Игры в OAuth Playground позволили подобрать нужный scope (легко угадываемый, но в документации почему-то не нашел) —
Начинаем писать скрипт, используем oAuth 1.0:https://www.googleapis.com/auth/cloudprint
Аутентификация через oAuth 1.0
function authorize_() { var oauthConfig = UrlFetchApp.addOAuthService("print"); oauthConfig.setConsumerKey("anonymous"); oauthConfig.setConsumerSecret("anonymous"); oauthConfig.setRequestTokenUrl("https://www.google.com/accounts/OAuthGetRequestToken?scope=https://www.googleapis.com/auth/cloudprint"); oauthConfig.setAuthorizationUrl("https://accounts.google.com/OAuthAuthorizeToken"); oauthConfig.setAccessTokenUrl("https://www.google.com/accounts/OAuthGetAccessToken"); } function invokeCloudPrint_(method,payload) { var baseurl = "https://www.google.com/cloudprint/"; var options = { method: "post", muteHttpExceptions: true, oAuthServiceName: "print", oAuthUseToken: "always" }; if (payload != undefined) options.payload = payload; authorize_(); var response = UrlFetchApp.fetch(baseurl+method,options); if (response.getResponseCode() == 403) { Browser.msgBox("Please authorize me to print!"); } return JSON.parse(response.getContentText()); } function test() { var searchAnswer = invokeCloudPrint_("search"); Logger.log(searchAnswer); }
После запуска функции test() появляется запрос авторизации, после чего всё отлично отрабатывает и в логе консоли виден ответ от Google Cloud Print. Проблема решена? Не совсем. Во-первых, как выяснилось, авторизация отрабатывает только в том случае, если её запустить из редактора скриптов. То есть пользователь копии скрипта должен зайти в редактор скриптов и там вызвать любую функцию, которая обратится к Google Cloud Print с запросом авторизации. Во-вторых,…
oAuth 2.0
...oAuth 1.0 доживает последние месяцы и после 20 апреля 2015 года поддержка данного протокола не гарантируется. При переходе к oAuth 2.0 авторизации в сервисах Google, при необходимости тиражировать решение, возникает проблема с client_id и редиректом. А именно, в аутентификационном запросе указывается уникальный client_id, ему соответствует определенный URL редиректа (или несколько URL) после аутентификации и секретный пароль. В общих чертах процесс переадресации идет по следующему сценарию:
- Отправили пользователя на страницу запроса авторизации.
- На URL редиректа получили ответ с кодом.
- Получили из кода токен для доступа к сервисам.
Проблема возникает именно с редиректом, поскольку каждый скрипт имеет в облаке уникальный идентификатор, и URL редиректа должен соответствовать этому идентификатору. Поэтому, в тиражируемом решении, есть такие варианты:
- объяснять каждому клиенту, как регистрировать oAuth 2.0 client_id в Google Developer Console;
- или каждый раз у себя делать новый client_id с URL редиректа, соответствующим новой копии скрипта (завязка на свой аккаунт);
- или написать универсальный скрипт, который будет по переданным параметрам генерировать токен… но, опять-таки, скрипт будет завязан на аккаунт разработчика и при любых проблемах с этим аккаунтом программа просто перестанет функционировать у всех клиентов.
Все эти методы не очень удобны, они или для себя (первый) или для in-house разработки (второй, иногда третий). К сожалению, сама архитектура oAuth не предполагает возможности, что что-то изменится в данном отношении. Я бы рекомендовал для тиражируемого решения третий вариант или, если клиент согласен предоставить доступ к своему аккаунту/создать нейтральный новый, — первый.
Я приведу пример кода по первому варианту, поскольку третий вариант я не стал писать, только продумал, а второй по коду ничем не отличается от первого, разница только в том, где создается client_id — у клиента или у разработчика.
Аутентификация через oAuth 2.0
Должно получиться примерно так:

Здесь все просто — показываем пользователю кнопку, которая перебросит его на URL для авторизации по oAuth 2.0. Редирект пойдет назад в указанную нами функцию:
Auth.html:
Здесь ключевой является функция ScriptApp.newStateToken(), которая позволяет создать параметр для метода usercallback, влекущий вызов указанной функции (getAuthResponse). При запуске функции test() откроется диалоговое окно на вкладке таблицы с кнопкой для перехода на страницу авторизации.
После обратного вызова мы попадем в getAuthResponse(). Напишем этот метод и вызовем какой-либо метод Google Cloud Print с полученным токеном, отобразив результат на экране:
Если все сделано правильно — в результате после нажатия кнопки Authorize и авторизации в открывшемся окне, на экране отобразится JSON-ский ответ со списком подключенных принтеров.
Шаг 1. Создаем client_id
- Открываем Google Developers Console и создаем новый проект.
- Переходим в APIs&auth ->Credentials, нажимаем Create new Client ID.
- Тип — Web Application; Authorized JavaScript origins — script.google.com/; Authorized redirect URIs — смотрим вверху Script Editor URL нашего скрипта, не включая /edit и далее, добавляя в конце /usercallback
Должно получиться примерно так:

Шаг 2. Код для авторизации
Здесь все просто — показываем пользователю кнопку, которая перебросит его на URL для авторизации по oAuth 2.0. Редирект пойдет назад в указанную нами функцию:
function test() { var html = HtmlService.createTemplateFromFile("Auth").evaluate().setSandboxMode(HtmlService.SandboxMode.NATIVE).setTitle("Test"); SpreadsheetApp.getUi().showModalDialog(html, "Test"); } function getAuthURL() { var options= { client_id : "110560935370-jdoq9cc7tvna2r94va4j9o3310m6ghth.apps.googleusercontent.com", // заменить на свой scope : "https://www.googleapis.com/auth/cloudprint", redirect_uri : "https://script.google.com/macros/d/MDYeOxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/usercallback", // заменить на свой state : ScriptApp.newStateToken().withMethod("getAuthResponse").createToken() }; var url = "https://accounts.google.com/o/oauth2/auth?response_type=code&access_type=offline"; for(var i in options) url += "&"+i+"="+encodeURIComponent(options[i]); return url; }
Auth.html:
<a href='<?!= getAuthURL(); ?>' target='_blank'> <button>Authorize!</button> </a>
Здесь ключевой является функция ScriptApp.newStateToken(), которая позволяет создать параметр для метода usercallback, влекущий вызов указанной функции (getAuthResponse). При запуске функции test() откроется диалоговое окно на вкладке таблицы с кнопкой для перехода на страницу авторизации.
Шаг 3. Получение oAuth token и вызов Google Cloud Print
После обратного вызова мы попадем в getAuthResponse(). Напишем этот метод и вызовем какой-либо метод Google Cloud Print с полученным токеном, отобразив результат на экране:
function getAuthResponse(q) { var options = { method: "post", muteHttpExceptions: true, payload: { code: q.parameter.code, client_id : "110560935370-jdoq9cc7tvna2r94va4j9o3310m6ghth.apps.googleusercontent.com", // заменить на свой client_secret : "xxxxxxxxxxxxxxxxxxxxxxxx", // заменить на свой redirect_uri: "https://script.google.com/macros/d/MDYeOxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/usercallback", // заменить на свой grant_type: "authorization_code" } } var response = JSON.parse(UrlFetchApp.fetch("https://accounts.google.com/o/oauth2/token", options)); var auth_string = response.token_type+" "+response.access_token; options.method = "get"; options.payload = null; options.headers = {Authorization: auth_string}; response = UrlFetchApp.fetch("https://www.google.com/cloudprint/search",options); return ContentService.createTextOutput(response.getContentText()); }
Если все сделано правильно — в результате после нажатия кнопки Authorize и авторизации в открывшемся окне, на экране отобразится JSON-ский ответ со списком подключенных принтеров.
Еще один метод, не буду рекомендовать, но для «себя» подойдет и проще в исполнении:
Грязный хак
Вообще говоря, Google Apps Script поддерживает собственный токен авторизации oAuth 2.0. Его можно получить вызовом ScriptApp.getOAuthToken(). Но в данном токене, разумеется, никаких прав доступа к Google Cloud Print не предусмотрено.
Тем не менее, существует способ данные права в него добавить. Для этого нужно вызвать окно запроса авторизации (при необходимости, сбросив текущий токен вызовом ScriptApp.invalidateAuth()) и скопировать URL данного окна (окно закрыть без подтверждения!):

В скопированном URL один из параметров будет выглядеть, как «scope=https://+https://» (набор прав, необходимых скрипту). Достаточно добавить в конце данного параметра
Тем не менее, существует способ данные права в него добавить. Для этого нужно вызвать окно запроса авторизации (при необходимости, сбросив текущий токен вызовом ScriptApp.invalidateAuth()) и скопировать URL данного окна (окно закрыть без подтверждения!):

В скопированном URL один из параметров будет выглядеть, как «scope=https://+https://» (набор прав, необходимых скрипту). Достаточно добавить в конце данного параметра
+https://www.googleapis.com/auth/cloudprintи открыть измененный URL в новой вкладке браузера, после чего подтвердить авторизацию. В результате, скрипт получит права доступа к Google Cloud Print и эти права сохранятся до момента переавторизации (если, например, вышеупомянутым вызовом invalidateAuth сбросить токен).
GCP Web Element
Из-за этих сложностей с oAuth 2.0 я решил попробовать GCP Web Element. Не очень долго копал данную тему, поскольку у меня уже были работающие варианты решения. Вкратце: результат полностью отрицательный. Дело в том, что Google Apps Script переписывает код JavaScript для отображения в браузере. В результате, GCP Web Element просто не срабатывает. Вот пример кода, создания гаджета не происходит:
GCP Web Element
Code.gs:
Print.html:
function test() { var html = HtmlService.createTemplateFromFile("Print").evaluate().setSandboxMode(HtmlService.SandboxMode.NATIVE).setTitle("Test"); SpreadsheetApp.getUi().showModalDialog(html, "Test"); }
Print.html:
<button onclick="alert(window.gadget); window.gadget=new cloudprint.Gadget(); alert(window.gadget);">Initiate Gadget</button> <script src="https://www.google.com/cloudprint/client/cpgadget.js" />
В итоге я остановился пока на oAuth 1.0, как на наиболее тиражируемом варианте (хоть и работоспособен метод до 20 апреля, тем не менее, как первое решение он подходит лучше — проще объяснить клиенту и клиент не будет напуган сложностью oAuth 2.0).
Контент и печать
Если бы API Google Apps Script работал бы так, как указано в документации, жизнь, несомненно была бы намного проще. Google Spreadsheet (точнее, приложение для работы с таблицей SpreadsheetApp) поддерживает конвертацию «на лету» в pdf:
function test() { var pdf = SpreadsheetApp.getActiveSpreadsheet().getAs("application/pdf"); }
Идея была в том, чтобы перенести выбранный диапазон в новую Spreadsheet и конвертировать её в pdf. К сожалению, мешает баг в Google Apps Script — PDF документ создается, но он абсолютно пуст, поэтому данный путь отпадает. Варианты обхода:
- Google Cloud Print умеет печатать Google Spreadsheet, как оказалось. Можно перенести выбор в новую таблицу и отдать команду на печать.
- Более элегантный путь: в меню Google Spreadsheet есть опция «Download as...» с возможностью выбора PDF-формата. И этот вариант, в отличие от конвертации силами Google Apps Script, работает.
Во втором варианте браузер переходит по специально сформированной ссылке. Напишем код, превращающий переданный диапазон Spreadsheet в PDF:
Конвертация в PDF
function cloudPrint_(strrange,portrait) { var searchAnswer = invokeCloudPrint_("search"); var ss = SpreadsheetApp.getActiveSpreadsheet(); var rangess = ss.getRange(strrange); var gid = rangess.getSheet().getSheetId(); var r1=rangess.getRow()-1; var c1=rangess.getColumn()-1; var r2=r1+rangess.getHeight(); var c2=c1+rangess.getWidth(); var docurl="https://docs.google.com/spreadsheets/d/"+SpreadsheetApp.getActiveSpreadsheet().getId()+"/export?format=pdf&size=0&fzr=false&portrait="+portrait+"&fitw=true&gid="+gid+"&r1="+r1+"&c1="+c1+"&r2="+r2+"&c2="+c2+"&ir=false&ic=false&gridlines=false&printtitle=false&sheetnames=false&pagenum=UNDEFINED&attachment=true"; return docurl; } function test() { Logger.log(cloudPrint_("A1:D12",true)); }
Отлично, URL получен! Осталось сущая мелочь — выгрузить файл и передать запрос в Google Cloud Print, чтобы насладиться печатью. Дополнительно необходимо указать printerid (список id возвращается методом API search) и xsrf из ранее полученного ответа:
Попытка 1. Не работает
function test() { var searchAnswer = invokeCloudPrint_("search"); var url = cloudPrint_("A1:D12",true); var file = UrlFetchApp.fetch(url); var payload = { printerid: printer, xsrf: searchAnswer.xsrf_token, title: rangess.getSheet().getName(), ticket: "{\"version\": \"1.0\",\"print\": {}}", contentType: "application/pdf", content: file.getBlob() }; var printstatus = invokeCloudPrint_("submit",payload); Browser.msgBox(printstatus.message); }
Но данный код не работает, проблемы возникают в двух местах. Во-первых, oAuth 1.0 отваливается и не срабатывает при попытке передать файл (привет багам Google Apps Script). Во-вторых, контекст аутентификации скрипта не совпадает с контекстом пользователя, вызвавшего скрипт, и к URL для выгрузки просто нет доступа. Получается, необходимо открывать на время печати spreadsheet для «внешнего мира» и закрывать по окончании печати. Но тогда нет смысла в промежуточной выгрузке PDF (все равно не работает с oAuth), можно сразу передать URL выгрузки в Google Cloud Print:
Попытка 2. Работает!
function test() { var searchAnswer = invokeCloudPrint_("search"); var url = cloudPrint_("A1:D12",true); var payload = { printerid: printer, xsrf: searchAnswer.xsrf_token, title: rangess.getSheet().getName(), ticket: "{\"version\": \"1.0\",\"print\": {}}", contentType: "url", content: url }; var drivefile = DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId()); var oldaccess = drivefile.getSharingAccess(); var oldpermission = drivefile.getSharingPermission(); drivefile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW); var printstatus = invokeCloudPrint_("submit",payload); drivefile.setSharing(oldaccess, oldpermission); Browser.msgBox(printstatus.message); }
Часть 3. Итоги
В итоге, после путешествия по лабиринту багов и проблем, печать заработала. Привожу полный код с oAuth 1.0 (как самодостаточное решение):
Печать из Google Apps Script
var contextauth=false; function cloudPrint_(strrange,portrait,size) { var searchAnswer = invokeCloudPrint_("search"); var ss = SpreadsheetApp.getActiveSpreadsheet(); var rangess = ss.getRange(strrange); var gid = rangess.getSheet().getSheetId(); var r1=rangess.getRow()-1; var c1=rangess.getColumn()-1; var r2=r1+rangess.getHeight(); var c2=c1+rangess.getWidth(); portrait = typeof portrait !== 'undefined' ? portrait : true; size = typeof size !== 'undefined' ? size : 0; var docurl="https://docs.google.com/spreadsheets/d/"+SpreadsheetApp.getActiveSpreadsheet().getId()+"/export?format=pdf&size=0&fzr=false&portrait="+portrait+"&fitw=true&gid="+gid+"&r1="+r1+"&c1="+c1+"&r2="+r2+"&c2="+c2+"&ir=false&ic=false&gridlines=false&printtitle=false&sheetnames=false&pagenum=UNDEFINED&attachment=true"; var prop = PropertiesService.getUserProperties(); var printer = prop.getProperty("printer"); if (printer == null) { selectPrinterDlg(strrange,portrait,size); return; } ss.toast("Printing..."); var drivefile = DriveApp.getFileById(SpreadsheetApp.getActiveSpreadsheet().getId()); var oldaccess = drivefile.getSharingAccess(); var oldpermission = drivefile.getSharingPermission(); drivefile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW); var payload={ printerid: printer, xsrf: searchAnswer.xsrf_token, title: rangess.getSheet().getName(), ticket: "{\"version\": \"1.0\",\"print\": {}}", contentType: "url", content: docurl }; var printstatus = invokeCloudPrint_("submit",payload); drivefile.setSharing(oldaccess, oldpermission); Browser.msgBox(printstatus.message); } function selectPrinterDlg(strrange,portrait,size) { var searchAnswer = invokeCloudPrint_("search"); var ui = UiApp.createApplication(); var panel = ui.createVerticalPanel(); var lb = ui.createListBox(false).setId('lb').setName('lb'); strrange = typeof strrange !== 'undefined' ? strrange : ""; portrait = typeof portrait !== 'undefined' ? portrait : ""; size = typeof size !== 'undefined' ? size : ""; var hidden1 = ui.createTextBox().setVisible(false).setValue(strrange).setId("range").setName("range"); var hidden2 = ui.createTextBox().setVisible(false).setValue(portrait.toString()).setId("portrait").setName("portrait"); var hidden3 = ui.createTextBox().setVisible(false).setValue(size.toString()).setId("printsize").setName("printsize"); for (var i in searchAnswer.printers) { var connPrinter = searchAnswer.printers[i]; lb.addItem(connPrinter.displayName, connPrinter.id); } var button = ui.createButton("Save"); var handler = ui.createServerHandler("SavePrinter_").addCallbackElement(panel); button.addClickHandler(ui.createClientHandler().forEventSource().setEnabled(false).setText("Saving...")); button.addClickHandler(handler); panel.add(lb).setCellHorizontalAlignment(button, UiApp.HorizontalAlignment.CENTER); panel.add(hidden1); panel.add(hidden2); panel.add(button); ui.add(panel); SpreadsheetApp.getUi().showModalDialog(ui, "Select printer"); return; } function clear() { PropertiesService.getUserProperties().deleteProperty("printer"); ScriptApp.invalidateAuth(); } function SavePrinter_(e) { var ui = UiApp.getActiveApplication(); PropertiesService.getUserProperties().setProperty("printer", e.parameter.lb); ui.close(); if (e.parameter.range != "") cloudPrint_(e.parameter.range,e.parameter.portrait == "true",parseInt(e.parameter.printsize)); return ui; } function invokeCloudPrint_(method,payload) { var baseurl = "https://www.google.com/cloudprint/"; var options = { method: "post", muteHttpExceptions: true, oAuthServiceName: "print", oAuthUseToken: "always" }; if (payload != undefined) options.payload = payload; authorize_(); var response = UrlFetchApp.fetch(baseurl+method,options); if (response.getResponseCode() == 403) { Browser.msgBox("Please authorize me to print!"); } return JSON.parse(response.getContentText()); } function validate() { var searchAnswer = invokeCloudPrint_("search"); } function authorize_() { if (contextauth) return; var oauthConfig = UrlFetchApp.addOAuthService("print"); oauthConfig.setConsumerKey("anonymous"); oauthConfig.setConsumerSecret("anonymous"); oauthConfig.setRequestTokenUrl("https://www.google.com/accounts/OAuthGetRequestToken?scope=https://www.googleapis.com/auth/cloudprint"); oauthConfig.setAuthorizationUrl("https://accounts.google.com/OAuthAuthorizeToken"); oauthConfig.setAccessTokenUrl("https://www.google.com/accounts/OAuthGetAccessToken"); contextauth = true; } function onOpen() { SpreadsheetApp.getUi().createMenu("Printing").addItem("Select printer...", "selectPrinterDlg").addToUi(); } function Print() { cloudPrint_("A1:D12",true); }
Дополнительно к разобранным кусочкам кода сделан диалог (и пункт меню) для выбора принтера. Инструкция по установке:
- Предварительно: настроить Google Cloud Print, проверить тестовую печать
- Создать новую Google Spreadsheet, написать что-либо в диапазоне A1:D12
- Открыть Script Editor, создать новый пустой проект
- Скопировать код, сохранить, вызвать функцию validate — чтобы авторизовать все необходимые права
- Вызвать функцию Print. При первом вызове на вкладке таблицы откроется диалог выбора принтера
