How to decrypt AES with Google Apps Script - google-apps-script

I am trying to decrypt AES with GAS. The target of decryption is a document file retrieved by Amazon Selling Partner API.
The key, iv, and URL are obtained by the API, and I want to decrypt the data downloaded by accessing the URL with the key and iv.
However, the decrypted text is either empty or garbled.
Can you please tell me what is wrong with the following code? The code uses cCryptoGS, which is a wrapper library for CryptoJS.
const decrypt_test = () => {
const url = 'https://tortuga-prod-fe.s3-us-west-2.amazonaws.com/%2FNinetyDays/amzn1.tortuga.3.5d4685fe-cdf1-4f37-8dfc-a25b85468e34.T1J5QXLEXAMPLE';
const response = UrlFetchApp.fetch(url);
const file = response.getContentText();
const key = 'xiZ8FGT6pYo49ZwfvAplJxKgO0qW46Morzs5aEXAMPLE';
const iv = 'aoGh0rhbB3ALlCFKiEXAMPLE';
const enc_key = cCryptoGS.CryptoJS.enc.Base64.parse(key);
const enc_iv = cCryptoGS.CryptoJS.enc.Base64.parse(iv);
const cipherParams = cCryptoGS.CryptoJS.lib.CipherParams.create({
ciphertext: file//cCryptoGS.CryptoJS.enc.Base64.parse(file)
});
console.log(`enc_key_length:${enc_key.words.length}`);
console.log(`enc_iv_length:${enc_iv.words.length}`);
const decryptedMessage = cCryptoGS.CryptoJS.AES.decrypt(cipherParams, enc_key, { iv: enc_iv, mode: cCryptoGS.CryptoJS.mode.CBC}).toString();
console.log(`decryptedMessage:${decryptedMessage}`);
return decryptedMessage;
};
[output]
2021/06/20 20:04:04 debug enc_key_length:8
2021/06/20 20:04:04 debug enc_iv_length:4
2021/06/20 20:04:04 debug decryptedMessage:bfc095f3ecec221e8585ceb68031078d25112f5f26ea2c1f80470f5f4f19f2e1c2cd94638e8666c3486fa29191b568bcd9e8d5a3bdcbbc05456f0567bb6cdae675fa044f94e560379d16b1d370cd7c4a9c5afbbcf4fde2694ed01c1b7950eaabc65e46c4640d8f0814bfe66e8ae65f7768136ac4615624be25373d665ee8fde82742e26664d7c09c61ac8994dc3052f0f22d5042f0b407d696e3c84a3906350dc60c46001ef7865d0c6594c57c5af22616688e028f52d4f12b538d0580c420fdcb0ee61287d4ee2629cd7d39f739d63e84dd75e948eaffb4383076f0c66997

The following code solved the problem
const decrypt_test = () => {
const url = 'https://tortuga-prod-fe.s3-us-west-2.amazonaws.com/%2FNinetyDays/EXAMPLE';
let options = {
'method': 'get',
'muteHttpExceptions': true,
};
const response = UrlFetchApp.fetch(url, options);
const file = response.getBlob().getBytes();
const key = 'xiZ8FGT6pYo49ZwfvAplJxKgO0qW46MoEXAMPLE';
const iv = 'aoGh0rhbB3ALlCFKiuJEXAMPLE';
const enc_key = cCryptoGS.CryptoJS.enc.Base64.parse(key);
const enc_iv = cCryptoGS.CryptoJS.enc.Base64.parse(iv);
const cipherParams = cCryptoGS.CryptoJS.lib.CipherParams.create({
ciphertext: cCryptoGS.CryptoJS.enc.Hex.parse(hexes(file))
});
const decryptedMessage = cCryptoGS.CryptoJS.AES.decrypt(cipherParams, enc_key,
{ iv: enc_iv, mode: cCryptoGS.CryptoJS.mode.CBC}).toString();
console.log(`decryptedMessage:${decryptedMessage}`);
const bin = bytes(decryptedMessage)
const myBlob = Utilities.newBlob(bin, MimeType.TEXT, "decrypted.csv");
DriveApp.createFile(myBlob);
};
const bytes = (hexstr) => {
ary = [];
for (var i = 0; i < hexstr.length; i += 2) {
ary.push(parseInt(hexstr.substr(i, 2), 16));
}
return ary;
}
const hexes = (ary) => {
return ary.map((e) => ( '00' + (e < 0 ? e += 0x0100 : e).toString(16)).slice(-2)).join('')
}

Related

Why do Google Apps Script and Crypto.subtle generate different RSA signatures?

I can't figure out why these two pieces of code generate different signatures when using the same message and key as inputs. Does anyone know what I'm doing wrong?
I've tried putting \0 or \n at the end of either message.
I've tried using ASCII encoding instead of UTF-8 for Apps Script.
I've tried different salt lengths for crypto.subtle (but I think Apps Script must use salt length of 0, because it generates the same signature every time.)
Google AppsScript
function testSign() {
const message = "test";
const key = `-----BEGIN PRIVATE KEY-----
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
-----END PRIVATE KEY-----
`;
const sig = Utilities.computeRsaSha256Signature(message, key, Utilities.Charset.UTF_8);
console.log(sig.slice(0,4)); // [ 45, -76, -60, -59 ]
}
Crypto.subtle:
const message = "test";
const pemContents = `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`;
function str2ab(str) {
const buf = new ArrayBuffer(str.length);
const bufView = new Uint8Array(buf);
for (let i = 0, strLen = str.length; i < strLen; i++) {
bufView[i] = str.charCodeAt(i);
}
return buf;
}
function importRsaKey(pemContents) {
const binaryDerString = window.atob(pemContents);
const binaryDer = str2ab(binaryDerString);
return window.crypto.subtle.importKey(
"pkcs8",
binaryDer,
{
name: "RSA-PSS",
hash: "SHA-256"
},
true,
["sign"]
);
}
const key = await importRsaKey(pemContents);
const sig = await window.crypto.subtle.sign(
{
name: "RSA-PSS",
saltLength: 0,
},
key,
(new TextEncoder()).encode(message)
);
const sigArray = new Int8Array(sig);
console.log(sigArray[0], sigArray[1], sigArray[2], sigArray[3]); // -97 -106 92 29
Looks like the problem was the algorithm for crypto.subtle. Following code for crypto.subtle matches appsscript:
const message = "test";
const pemContents = `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`;
function str2ab(str) {
const buf = new ArrayBuffer(str.length);
const bufView = new Uint8Array(buf);
for (let i = 0, strLen = str.length; i < strLen; i++) {
bufView[i] = str.charCodeAt(i);
}
return buf;
}
function importRsaKey(pemContents) {
const binaryDerString = window.atob(pemContents);
const binaryDer = str2ab(binaryDerString);
return window.crypto.subtle.importKey(
"pkcs8",
binaryDer,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256"
},
true,
["sign"]
);
}
const key = await importRsaKey(pemContents);
const sig = await window.crypto.subtle.sign(
{
name: "RSASSA-PKCS1-v1_5"
},
key,
(new TextEncoder()).encode(message)
);
const sigArray = new Int8Array(sig);
console.log(sigArray[0], sigArray[1], sigArray[2], sigArray[3]); // 45 -76 -60 -59

Scraping <meta> content with Cheerio

Tanaike helped me with this amazing script using Cheerio. The original was for Letterboxd, but this one is to pull in a watchlist from Trakt.tv (sample watchlist).
As it is right now it pulls in the watched date and the title, but I'd also like to pull in the content from the meta tag for each item.
<meta content="8 Million Ways to Die (1986)" itemprop="name">
I tried using $('[itemprop="name"]').attr('content'); but it doesn't accept the .attr piece.
Here is the full script as it is now, returning the watched date in the Col1 and the title in Col2.
/**
* Returns Trakt watchlist by username
* #param pages enter the number of pages in the list. Default is 10
* #customfunction
*/
function TRAKT(pages=10) {
const username = `jerrylaslow`
const maxPage = pages;
const reqs = [...Array(maxPage)].map((_, i) => ({ url: `https://trakt.tv/users/`+ username +`/history/all/added?page=${i + 1}`, muteHttpExceptions: true }));
return UrlFetchApp.fetchAll(reqs).flatMap((r, i) => {
if (r.getResponseCode() != 200) {
return [["Values couldn't be retrieved.", reqs[i].url]];
}
const $ = Cheerio.load(r.getContentText());
const ar = $(`a.titles-link > h3.ellipsify, h4 > span.format-date`).toArray();
return [...Array(Math.ceil(ar.length / 2))].map((_) => {
const temp = ar.splice(0, 2);
const watchDate = Utilities.formatDate(new Date($(temp[1]).text().trim().replace(/T|Z/g, " ")),"GMT","yyyy-MM-dd");
const title = $(temp[0]).text().trim();
return [watchDate,title];
});
});
}
The values can be pulled with this, so I know there isn't any sort of blocking in play.
=IMPORTXML(
"https://trakt.tv/users/jerrylaslow/history",
"//meta[#itemprop='name']/#content")
Any help is appreciated.
In order to achieve your goal, in your script, how about the following modification?
Modified script:
function TRAKT(pages = 10) {
const username = `jerrylaslow`
const maxPage = pages;
const reqs = [...Array(maxPage)].map((_, i) => ({ url: `https://trakt.tv/users/` + username + `/history/all/added?page=${i + 1}`, muteHttpExceptions: true }));
return UrlFetchApp.fetchAll(reqs).flatMap((r, i) => {
if (r.getResponseCode() != 200) {
return [["Values couldn't be retrieved.", reqs[i].url]];
}
const $ = Cheerio.load(r.getContentText());
const ar = $(`a.titles-link > h3.ellipsify, h4 > span.format-date`).toArray();
return [...Array(Math.ceil(ar.length / 2))].map((_) => {
const temp = ar.splice(0, 2);
const c = $(temp[0]).parent('a').parent('div').parent('div').find('meta').toArray().find(ff => $(ff).attr("itemprop") == "name"); // Added
const watchDate = Utilities.formatDate(new Date($(temp[1]).text().trim().replace(/T|Z/g, " ")), "GMT", "yyyy-MM-dd");
const title = $(temp[0]).text().trim();
return [watchDate, title, c ? $(c).attr("content") : ""]; // Modified
});
});
}
When this modified script is run, the value of content is put to 3rd column. If you want to put it to other column, please modify return [watchDate, title, c ? $(c).attr("content") : ""];.

Rooms with Forge

My goal is to see the Revit rooms in the Forge viewer. The application is in .NET Core. I have tried implementing GenerateMasterViews.
The code I am using to achieve this is:
[Route("api/forge/modelderivative/jobs")]
public async Task<dynamic> TranslateObject([FromBody]TranslateObjectModel objModel)
{
dynamic oauth = await OAuthController.GetInternalAsync();
// prepare the payload
var advOutputPayload = new JobSvf2OutputPayloadAdvanced();
advOutputPayload.GenerateMasterViews = true;
List<JobPayloadItem> outputs = new List<JobPayloadItem>()
{
new JobPayloadItem(
JobPayloadItem.TypeEnum.Svf2,
new List<JobPayloadItem.ViewsEnum>()
{
JobPayloadItem.ViewsEnum._2d,
JobPayloadItem.ViewsEnum._3d
},
advOutputPayload
)
};
JobPayload job;
job = new JobPayload(new JobPayloadInput(objModel.objectName), new JobPayloadOutput(outputs));
// start the translation
DerivativesApi derivative = new DerivativesApi();
derivative.Configuration.AccessToken = oauth.access_token;
dynamic jobPosted = await derivative.TranslateAsync(job);
return jobPosted;
}
Autodesk.Viewing.Initializer(options, () => {
viewer = new Autodesk.Viewing.GuiViewer3D(document.getElementById('forgeViewer'));
viewer.start();
var documentId = 'urn:' + urn;
Autodesk.Viewing.Document.load(documentId, onDocumentLoadSuccess, onDocumentLoadFailure);
});
}
function onDocumentLoadSuccess(doc) {
var viewables = doc.getRoot().getDefaultGeometry();
viewer.loadDocumentNode(doc, viewables).then(i => {
// documented loaded, any action?
});
}
But I can't get it to work.
I have looked for information, but this url: https://forge.autodesk.com/en/docs/model-derivative/v2/tutorials/prep-roominfo4viewer/option2/ and this url:
https://forge.autodesk.com/en/docs/model-derivative/v2/tutorials/prep-roominfo4viewer/option1/ they don't work and I couldn't see how to do it.
To check if the object is in the room, we can do the following:
Get bounds for each room and object
getBoundingBox(dbId, model) {
const it = model.getInstanceTree();
const fragList = model.getFragmentList();
let bounds = new THREE.Box3();
it.enumNodeFragments(dbId, (fragId) => {
let box = new THREE.Box3();
fragList.getWorldBounds(fragId, box);
bounds.union(box);
}, true);
return bounds;
}
Iterate rooms and objects and use containsBox or containsPoint to check if their bounding box has intersection.
If you want to do an acute collision check, you can take advantage of the ThreeCSG.js to do geometry intersection. Here is a blog post demonstrating how to integrate ThreeCSG.js with Forge Viewer.
https://forge.autodesk.com/blog/boolean-operations-forge-viewer
Note. This process would reduce the viewer performance since JavaScript is running on a single thread on the Web Browser, so you may use some technologies like the web worker to do the complex calculations on a separate thread.
Update:
Here is a working sample extension demonstrating the above idea:
/////////////////////////////////////////////////////////////////////
// Copyright (c) Autodesk, Inc. All rights reserved
// Written by Forge Partner Development
//
// Permission to use, copy, modify, and distribute this software in
// object code form for any purpose and without fee is hereby granted,
// provided that the above copyright notice appears in all copies and
// that both that copyright notice and the limited warranty and
// restricted rights notice below appear in all supporting
// documentation.
//
// AUTODESK PROVIDES THIS PROGRAM "AS IS" AND WITH ALL FAULTS.
// AUTODESK SPECIFICALLY DISCLAIMS ANY IMPLIED WARRANTY OF
// MERCHANTABILITY OR FITNESS FOR A PARTICULAR USE. AUTODESK, INC.
// DOES NOT WARRANT THAT THE OPERATION OF THE PROGRAM WILL BE
// UNINTERRUPTED OR ERROR FREE.
/////////////////////////////////////////////////////////////////////
(function () {
const Utility = {
/**
* Rest an object
* #param {Object} obj An object to be reset.
* ref: https://stackoverflow.com/a/24090180
*/
resetObject: function (obj) {
for (let key in Object.getOwnPropertyNames(obj)) {
if (!obj.hasOwnProperty(key)) continue;
let val = obj[key];
switch (typeof val) {
case 'string':
obj[key] = ''; break;
case 'number':
obj[key] = 0; break;
case 'boolean':
obj[key] = false; break;
case 'object':
if (val === null) break;
if (val instanceof Array) {
while (obj[key].length > 0) {
obj[key].pop();
}
break;
}
val = {};
//Or recursively clear the sub-object
//resetObject(val);
break;
}
}
}
};
/**
* A Forge Viewer extension for loading and rendering Revit Grids by AEC Model Data
* #class
*/
class RoomLocatorExtension extends Autodesk.Viewing.Extension {
constructor(viewer, options) {
super(viewer, options);
this.roomCategoryName = options.roomCategoryName || 'Revit Rooms';//'Revit Habitaciones'
this.onContextMenu = this.onContextMenu.bind(this);
}
onContextMenu(menu, status) {
if (status.hasSelected) {
menu.push({
title: 'Find room',
target: async () => {
let selSet = this.viewer.getSelection();
this.viewer.clearSelection();
const roomDbIds = await this.locateElementByRoom(selSet[0]);
if (!roomDbIds || roomDbIds.length <= 0) return;
this.viewer.select(roomDbIds);
}
});
}
}
async getPropertiesAsync(dbId, model) {
return new Promise((resolve, reject) => {
model.getProperties2(
dbId,
(result) => resolve(result),
(error) => reject(error)
);
});
}
async getElementsByCategoryAsync(category) {
return new Promise((resolve, reject) => {
this.viewer.search(
category,
(dbIds) => resolve(dbIds),
(error) => reject(error),
['Category'],
{ searchHidden: true }
);
});
}
async getRoomDbIds() {
try {
const roomDbIds = await this.getElementsByCategoryAsync(this.roomCategoryName);
if (!roomDbIds || roomDbIds.length <= 0) {
throw new Error('No Rooms found in current model');
}
return roomDbIds;
} catch (ex) {
console.warn(`[RoomLocatorExtension]: ${ex}`);
throw new Error('No room found');
}
}
getBoundingBox(dbId, model) {
const it = model.getInstanceTree();
const fragList = model.getFragmentList();
let bounds = new THREE.Box3();
it.enumNodeFragments(dbId, (fragId) => {
let box = new THREE.Box3();
fragList.getWorldBounds(fragId, box);
bounds.union(box);
}, true);
return bounds;
}
getLeafFragIds(model, leafId) {
const instanceTree = model.getData().instanceTree;
const fragIds = [];
instanceTree.enumNodeFragments(leafId, function (fragId) {
fragIds.push(fragId);
});
return fragIds;
}
getComponentGeometryInfo(dbId, model) {
const viewer = this.viewer;
const viewerImpl = viewer.impl;
const fragIds = this.getLeafFragIds(model, dbId);
let matrixWorld = null;
const meshes = fragIds.map((fragId) => {
const renderProxy = viewerImpl.getRenderProxy(model, fragId);
const geometry = renderProxy.geometry;
const attributes = geometry.attributes;
const positions = geometry.vb ? geometry.vb : attributes.position.array;
const indices = attributes.index.array || geometry.ib;
const stride = geometry.vb ? geometry.vbstride : 3;
const offsets = geometry.offsets;
matrixWorld = matrixWorld || renderProxy.matrixWorld.elements;
return {
positions,
indices,
offsets,
stride
};
});
return {
matrixWorld,
meshes
};
}
getComponentGeometry(data, vertexArray) {
const offsets = [
{
count: data.indices.length,
index: 0,
start: 0
}
];
for (let oi = 0, ol = offsets.length; oi < ol; ++oi) {
let start = offsets[oi].start;
let count = offsets[oi].count;
let index = offsets[oi].index;
for (let i = start, il = start + count; i < il; i += 3) {
const a = index + data.indices[i];
const b = index + data.indices[i + 1];
const c = index + data.indices[i + 2];
const vA = new THREE.Vector3();
const vB = new THREE.Vector3();
const vC = new THREE.Vector3();
vA.fromArray(data.positions, a * data.stride);
vB.fromArray(data.positions, b * data.stride);
vC.fromArray(data.positions, c * data.stride);
vertexArray.push(vA);
vertexArray.push(vB);
vertexArray.push(vC);
}
}
}
buildComponentMesh(data) {
const vertexArray = [];
for (let idx = 0; idx < data.nbMeshes; ++idx) {
const meshData = {
positions: data['positions' + idx],
indices: data['indices' + idx],
stride: data['stride' + idx]
}
this.getComponentGeometry(meshData, vertexArray);
}
const geometry = new THREE.Geometry();
for (let i = 0; i < vertexArray.length; i += 3) {
geometry.vertices.push(vertexArray[i]);
geometry.vertices.push(vertexArray[i + 1]);
geometry.vertices.push(vertexArray[i + 2]);
const face = new THREE.Face3(i, i + 1, i + 2);
geometry.faces.push(face);
}
const matrixWorld = new THREE.Matrix4();
matrixWorld.fromArray(data.matrixWorld);
const mesh = new THREE.Mesh(geometry);
mesh.applyMatrix(matrixWorld);
mesh.boundingBox = data.boundingBox;
mesh.bsp = new ThreeBSP(mesh)
mesh.dbId = data.dbId;
return mesh;
}
buildCsgMesh(dbId, model) {
const geometry = this.getComponentGeometryInfo(dbId, model);
const data = {
boundingBox: this.getBoundingBox(dbId, model),
matrixWorld: geometry.matrixWorld,
nbMeshes: geometry.meshes.length,
dbId
};
geometry.meshes.forEach((mesh, idx) => {
data['positions' + idx] = mesh.positions;
data['indices' + idx] = mesh.indices;
data['stride' + idx] = mesh.stride;
});
return this.buildComponentMesh(data);
}
async buildBBoxes() {
try {
const model = this.viewer.model;
const roomBBoxes = {};
const roomDbIds = await this.getRoomDbIds();
for (let i = 0; i < roomDbIds.length; i++) {
let dbId = roomDbIds[i];
let bbox = await this.getBoundingBox(dbId, model);
roomBBoxes[dbId] = bbox;
}
this.cachedBBoxes['rooms'] = roomBBoxes;
} catch (ex) {
console.warn(`[RoomLocatorExtension]: ${ex}`);
throw new Error('Cannot build bounding boxes from rooms');
}
}
async locateElementByRoom(dbId) {
let bbox = await this.getBoundingBox(dbId, this.viewer.model);
const roomDbIds = Object.keys(this.cachedBBoxes['rooms']);
const roomBoxes = Object.values(this.cachedBBoxes['rooms']);
// Coarse Phase Collision
const coarseResult = [];
for (let i = 0; i < roomDbIds.length; i++) {
let roomDbId = roomDbIds[i];
let roomBox = roomBoxes[i];
if (roomBox.containsBox(bbox)) {
coarseResult.push(parseInt(roomDbId));
} else {
if (roomBox.containsPoint(bbox.min) || roomBox.containsPoint(bbox.max) || roomBox.containsPoint(bbox.center())) {
coarseResult.push(parseInt(roomDbId));
}
}
}
// Fine Phase Collision
const fineResult = [];
let elementCsgMesh = this.buildCsgMesh(dbId, this.viewer.model);
for (let i = 0; i < coarseResult.length; i++) {
let roomDbId = coarseResult[i];
let roomCsgMesh = this.buildCsgMesh(roomDbId, this.viewer.model);
let result = elementCsgMesh.bsp.intersect(roomCsgMesh.bsp);
if (result.tree.polygons.length <= 0) {
result = roomCsgMesh.bsp.intersect(elementCsgMesh.bsp);
// if (!this.viewer.overlays.hasScene('csg'))
// this.viewer.overlays.addScene('csg');
// else
// this.viewer.overlays.clearScene('csg');
// let mat = new THREE.MeshBasicMaterial({ color: 'red' })
// let mesh = result.toMesh(mat);
// this.viewer.overlays.addMesh(mesh, 'csg')
if (result.tree.polygons.length <= 0) continue;
}
fineResult.push(roomDbId);
}
return fineResult;
}
async load() {
await Autodesk.Viewing.Private.theResourceLoader.loadScript(
'https://cdn.jsdelivr.net/gh/Wilt/ThreeCSG#develop/ThreeCSG.js',
'ThreeBSP'
);
if (!window.ThreeBSP)
throw new Error('Cannot load ThreeCSG.js, please download a copy from https://github.com/Wilt/ThreeCSG/blob/develop/ThreeCSG.js')
await this.viewer.waitForLoadDone();
this.cachedBBoxes = {};
await this.buildBBoxes();
this.viewer.registerContextMenuCallback(
'RoomLocatorExtension',
this.onContextMenu
);
return true;
}
unload() {
Utility.resetObject(this.cachedBBoxes);
this.viewer.unregisterContextMenuCallback(
'RoomLocatorExtension',
this.onContextMenu
);
return true;
}
}
Autodesk.Viewing.theExtensionManager.registerExtension('RoomLocatorExtension', RoomLocatorExtension);
})();
Snapshots:

Extending script execution time beyond 5 minutes for reddit scraping

I'm attempting to gather all of the posts submitted to a particular subreddit using the code found here: https://www.labnol.org/internet/web-scraping-reddit/28369/ However the execution limit is reached well before this completes.
I am looking for a way to extend the run time of the script, with it ideally not needing my intervention at all once I click run.
const getThumbnailLink_ = url => {
if (!/^http/.test(url)) return '';
return `=IMAGE("${url}")`;
};
const getHyperlink_ = (url, text) => {
if (!/^http/.test(url)) return '';
return `=HYPERLINK("${url}", "${text}")`;
};
const writeDataToSheets_ = data => {
const values = data.map(r => [
new Date(r.created_utc * 1000),
r.title,
getThumbnailLink_(r.thumbnail),
getHyperlink_(r.url, 'Link'),
getHyperlink_(r.full_link, 'Comments')
]);
const sheet = SpreadsheetApp.getActiveSheet();
sheet.getRange(sheet.getLastRow() + 1, 1, values.length, values[0].length).setValues(values);
SpreadsheetApp.flush();
};
const isRateLimited_ = () => {
const response = UrlFetchApp.fetch('https://api.pushshift.io/meta');
const { server_ratelimit_per_minute: limit } = JSON.parse(response);
return limit < 1;
};
const getAPIEndpoint_ = (subreddit, before = '') => {
const fields = ['title', 'created_utc', 'url', 'thumbnail', 'full_link'];
const size = 10000;
const base = 'https://api.pushshift.io/reddit/search/submission';
const params = { subreddit, size, fields: fields.join(',') };
if (before) params.before = before;
const query = Object.keys(params)
.map(key => `${key}=${params[key]}`)
.join('&');
return `${base}?${query}`;
};
const scrapeReddit = (subreddit = 'AskMen') => {
let before = '';
do {
const apiUrl = getAPIEndpoint_(subreddit, before);
const response = UrlFetchApp.fetch(apiUrl);
const { data } = JSON.parse(response);
const { length } = data;
before = length > 0 ? String(data[length - 1].created_utc) : '';
if (length > 0) {
writeDataToSheets_(data);
}
} while (before !== '' && !isRateLimited_());
};
Generally it's a better practice to optimize your script to not reach the execution time defined by your quota. So in your case, one solution is to reduce the batch size per execution. In the reference you linked the code fetches 1000 posts per batch, your code fetches 10000.
Try with smaller values to see if the script execution time does not exceed quota anymore.
const getAPIEndpoint_ = (subreddit, before = '') => {
const fields = ['title', 'created_utc', 'url', 'thumbnail', 'full_link'];
const size = 1000;
const base = 'https://api.pushshift.io/reddit/search/submission';
const params = { subreddit, size, fields: fields.join(',') };
if (before) params.before = before;
const query = Object.keys(params)
.map(key => `${key}=${params[key]}`)
.join('&');
return `${base}?${query}`;
};
But if you have a business need to exceed your quota, you can upgrade to to either Google Workspace Basic, Business or Enterprise - depending on how much you need to increase your quota and how much you are willing to pay.
See here for more information about different accounts and pricing.

Export Form responses as csv Google Apps Scripts

Is there is a fast way to programmatically export all responses from a Google Form to a csv? Something like "Export responses to csv" invoked via Scripts.
Right now I'm doing it in a rock art way:
Iterate over the forms I want to export (~75)
Open each form var form = FormApp.openById(formId);
Get responses: var formReponses = form.getResponses(); (from 0 to 700 responses each form)
Iterate over responses and get item responses: var preguntes = formReponses[r].getItemResponses();
For each itemResponse, convert it to csv/json
Export responses to a drive file
This is extremly slow and additionally it hangs over and over, so I had to export responses in chunks of 50 responses and save them in Drive separated files. On next execution (after letting servers to cool down for a while), I'm executing the script again, skipping the number of responses found on the chunk file.
Additionally I'm not sure that Google keeps the responses order when doing form.getResponses(); (actually I've found that if the form has been modified, the order is not the same)
Is there a better way to do it?
Whith the help of #JackBrown I've managed to write a Chrome extension to download responses (maybe soon in github). This will wait for each download in the formIds object until finished and then prompt for the next one:
'use strict';
function startDownload() {
const formIds = {
'Downloads-subfolder-here': {
'Download-filename-here': '1-cx-aSAMrTK0IHsQkE... {form-id here}',
'Another-filename-here': '...-dnqdpnEso {form-id here}',
// ...
},
'Another-subfolder-here': {
'Download-filename-here': '1-cx-aSAMrTK0IHsQkE... {form-id here}',
'Another-filename-here': '...-dnqdpnEso {form-id here}',
// ...
},
};
const destFolders = Object.keys(formIds);
const downloads = [];
for (let t = 0, tl = destFolders.length; t < tl; t += 1) {
const destFolder = destFolders[t];
const forms = Object.keys(formIds[destFolder]);
for (let f = 0, fl = forms.length; f < fl; f += 1) {
const formName = forms[f];
downloads.push({
destFolder,
formName,
url: `https://docs.google.com/forms/d/${formIds[destFolder][formName]}/downloadresponses?tz_offset=-18000000`,
filename: `myfolder/${destFolder}/${formName.replace(/\//g, '_')}.csv`,
});
}
}
const event = new Event('finishedDownload');
const eventInterrupt = new Event('interruptedDownload');
let currId;
chrome.downloads.onChanged.addListener((downloadDelta) => {
if (downloadDelta.id === currId) {
if (downloadDelta.state && downloadDelta.state.current === 'complete') {
document.dispatchEvent(event);
} else if (downloadDelta.state && downloadDelta.state.current === 'interrupted') {
console.log(downloadDelta);
document.dispatchEvent(eventInterrupt);
}
}
});
downloads.reduce((promise, actual) => {
return promise.then((last) => (last ? new Promise((resolve) => {
const { url, filename, destFolder, formName } = actual;
function listener() {
document.removeEventListener('finishedDownload', listener);
document.removeEventListener('interruptedDownload', listener);
resolve(true);
};
function interrupt() {
document.removeEventListener('finishedDownload', listener);
document.removeEventListener('interruptedDownload', listener);
resolve(false);
}
console.log(`Processant ${destFolder}, ${formName}: ${url}`);
document.addEventListener('finishedDownload', listener);
document.addEventListener('interruptedDownload', interrupt);
chrome.downloads.download({ url, filename }, (downloadId) => {
currId = downloadId;
if (!downloadId) {
console.log();
console.log('Error downloading...');
console.log(runtime.lastError);
resolve();
}
});
}) : Promise.resolve(false)));
}, Promise.resolve(true));
}
chrome.browserAction.onClicked.addListener((/*tab*/) => startDownload());