TEST
/************************************************************
TLM PHOTOS — GOOGLE PHOTOS ALBUM API BRIDGE
Google Apps Script Web App
Album:
https://photos.app.goo.gl/TQ7pHKyAV7U3JgAF9
Returns JSON / JSONP for Blogger
************************************************************/
const ALBUM_URL =
'https://photos.app.goo.gl/TQ7pHKyAV7U3JgAF9';
const RPC_ID = 'snAcKc';
const BATCH_URL =
'https://photos.google.com/u/0/_/PhotosUi/data/batchexecute';
const MAX_PAGES = 100;
/* =========================================================
WEB APP
========================================================= */
function doGet(e) {
try {
const result = getAlbumPhotos();
const callback =
e &&
e.parameter &&
e.parameter.callback;
const json =
JSON.stringify(result);
if (callback) {
return ContentService
.createTextOutput(
callback + '(' + json + ');'
)
.setMimeType(
ContentService.MimeType.JAVASCRIPT
);
}
return ContentService
.createTextOutput(json)
.setMimeType(
ContentService.MimeType.JSON
);
} catch (error) {
const result = {
success: false,
error: String(error),
photos: []
};
const json =
JSON.stringify(result);
const callback =
e &&
e.parameter &&
e.parameter.callback;
if (callback) {
return ContentService
.createTextOutput(
callback + '(' + json + ');'
)
.setMimeType(
ContentService.MimeType.JAVASCRIPT
);
}
return ContentService
.createTextOutput(json)
.setMimeType(
ContentService.MimeType.JSON
);
}
}
/* =========================================================
MAIN
========================================================= */
function getAlbumPhotos() {
const cache =
CacheService.getScriptCache();
const cached =
cache.get('TLM_PHOTOS');
if (cached) {
return JSON.parse(cached);
}
const html =
fetchAlbum(ALBUM_URL);
/*
Extract the main Google Photos
AF_initDataCallback object.
*/
const phase1 =
extractInitData(html);
if (!phase1) {
throw new Error(
'Google Photos album data was not found.'
);
}
const data =
parseGoogleData(phase1);
if (!data) {
throw new Error(
'Unable to parse Google Photos album data.'
);
}
let photos =
parseMediaItems(data);
/*
Extract pagination information.
*/
let pageToken =
getNextPageToken(data);
/*
Extract album request keys.
*/
const request =
extractAlbumRequest(html);
/*
Fetch remaining pages.
*/
if (
pageToken &&
request
) {
let page = 1;
const seen =
{};
while (
pageToken &&
page < MAX_PAGES
) {
if (seen[pageToken]) {
break;
}
seen[pageToken] = true;
const response =
getAlbumPage(
request,
pageToken
);
if (!response) {
break;
}
const pageData =
response.data;
const more =
parseMediaItems({
data: pageData
});
if (more &&
more.length) {
photos =
photos.concat(more);
}
pageToken =
response.nextPageToken;
page++;
}
}
/*
Remove duplicate URLs.
*/
const unique =
{};
const finalPhotos =
photos.filter(function(photo) {
if (!photo.url) {
return false;
}
if (unique[photo.url]) {
return false;
}
unique[photo.url] = true;
return true;
});
const result = {
success: true,
album:
ALBUM_URL,
count:
finalPhotos.length,
updated:
new Date().toISOString(),
photos:
finalPhotos
};
/*
Cache for 5 minutes.
*/
cache.put(
'TLM_PHOTOS',
JSON.stringify(result),
300
);
return result;
}
/* =========================================================
FETCH ALBUM
========================================================= */
function fetchAlbum(url) {
const response =
UrlFetchApp.fetch(
url,
{
method: 'get',
followRedirects: true,
muteHttpExceptions: true,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140 Safari/537.36',
'Accept':
'text/html,application/xhtml+xml'
}
}
);
const code =
response.getResponseCode();
if (code < 200 || code >= 400) {
throw new Error(
'Google Photos returned HTTP ' +
code
);
}
return response.getContentText();
}
/* =========================================================
EXTRACT AF_initDataCallback
========================================================= */
function extractInitData(html) {
const regex =
/AF_initDataCallback\((\{[\s\S]*?)(\);<\/script>)/g;
let match;
let best = '';
while (
(match = regex.exec(html))
) {
const candidate =
match[1] || '';
if (
candidate.length >
best.length
) {
best = candidate;
}
}
return best || null;
}
/* =========================================================
PARSE GOOGLE DATA
Google Photos currently emits JSON5-like
JavaScript data. Apps Script V8 can evaluate
the extracted object.
========================================================= */
function parseGoogleData(text) {
try {
return Function(
'"use strict"; return (' +
text +
');'
)();
} catch (error) {
throw new Error(
'Google Photos data parsing failed: ' +
error
);
}
}
/* =========================================================
PARSE MEDIA ITEMS
Structure follows the public shared-page
metadata used by current extractors.
========================================================= */
function parseMediaItems(input) {
if (
!input ||
!Array.isArray(input.data)
) {
return [];
}
const arr =
input.data[1];
if (
!Array.isArray(arr)
) {
return [];
}
const result = [];
arr.forEach(function(item) {
if (
!Array.isArray(item) ||
item.length < 6
) {
return;
}
const uid =
item[0];
const detail =
item[1];
if (
typeof uid !== 'string' ||
!Array.isArray(detail) ||
detail.length < 3
) {
return;
}
const url =
detail[0];
const width =
detail[1];
const height =
detail[2];
if (
typeof url !== 'string' ||
typeof width !== 'number' ||
typeof height !== 'number'
) {
return;
}
result.push({
uid: uid,
url: url,
width: width,
height: height
});
});
return result;
}
/* =========================================================
NEXT PAGE TOKEN
========================================================= */
function getNextPageToken(input) {
if (
!input ||
!Array.isArray(input.data)
) {
return null;
}
const token =
input.data[2];
return (
typeof token === 'string' &&
token.length > 0
)
? token
: null;
}
/* =========================================================
EXTRACT ALBUM KEY + AUTH KEY
========================================================= */
function extractAlbumRequest(html) {
const regex =
/snAcKc[^}]*?request:\s*\[\s*"([A-Za-z0-9_-]+)"\s*,\s*null\s*,\s*null\s*,\s*"([A-Za-z0-9_-]+)"/s;
const match =
regex.exec(html);
if (!match) {
return null;
}
return {
albumKey:
match[1],
authKey:
match[2]
};
}
/* =========================================================
GET PAGINATED PAGE
========================================================= */
function getAlbumPage(
request,
pageToken
) {
const innerRequest =
JSON.stringify([
request.albumKey,
pageToken,
null,
request.authKey
]);
const envelope =
JSON.stringify([
[
[
RPC_ID,
innerRequest,
null,
'generic'
]
]
]);
const url =
BATCH_URL +
'?rpcids=' +
RPC_ID +
'&source-path=/share/' +
encodeURIComponent(
request.albumKey
);
const response =
UrlFetchApp.fetch(
url,
{
method: 'post',
muteHttpExceptions: true,
headers: {
'Content-Type':
'application/x-www-form-urlencoded;charset=UTF-8',
'Origin':
'https://photos.google.com',
'Referer':
'https://photos.google.com/share/' +
request.albumKey +
'?key=' +
request.authKey
},
payload: {
'f.req':
envelope
}
}
);
const body =
response.getContentText();
return parseBatchResponse(body);
}
/* =========================================================
PARSE BATCH RESPONSE
========================================================= */
function parseBatchResponse(body) {
let text =
body.trim();
if (
text.indexOf(")]}'") === 0
) {
text =
text.substring(4).trim();
}
const lines =
text.split('\n');
let responseLine =
null;
for (
let i = 0;
i < lines.length;
i++
) {
if (
lines[i]
.trim()
.indexOf('[[') === 0
) {
responseLine =
lines[i]
.trim();
break;
}
}
if (!responseLine) {
return null;
}
try {
const outer =
JSON.parse(
responseLine
);
let payload =
null;
outer.forEach(function(entry) {
if (
Array.isArray(entry) &&
entry[0] === 'wrb.fr' &&
entry[1] === RPC_ID &&
typeof entry[2] === 'string'
) {
payload =
entry[2];
}
});
if (!payload) {
return null;
}
const data =
JSON.parse(payload);
return {
data:
data,
nextPageToken:
(
Array.isArray(data) &&
typeof data[2] === 'string'
)
? data[2]
: null
};
} catch (error) {
return null;
}
}
0 Comments