gphotos-scraper
Version:
A tool to extract public url and metadata from shared album
178 lines (150 loc) • 4.75 kB
text/typescript
import { AlbumNotFoundException } from "../exceptions";
import type { GoogleAlbum } from "../interfaces";
import { apiClient } from "./axiosClient";
const RPCID = "snAcKc";
const BASE_URL = "https://photos.google.com/_/PhotosUi/data/batchexecute";
// Pre-compiled Regex for better performance
const RE_CANONICAL = /<link\s+rel="canonical"\s+href="([^"]+)"/;
const RE_KEY = /key=([^"&]+)/;
export const getData = async (
id: string,
key: string,
token?: string,
): Promise<GoogleAlbum> => {
const queryData = [[RPCID, JSON.stringify([id, token, null, key])]];
const params = new URLSearchParams();
params.append("f.req", JSON.stringify([queryData]));
let data: string;
try {
const res = await apiClient.post(BASE_URL, params, {
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
},
});
data = res.data.replace(/^\)\]\}'/, ""); // Faster regex replacement
} catch (error: any) {
console.error(error?.message || error);
throw error;
}
const list = JSON.parse(data);
const reponseData = list.find((item: any[]) => item?.[1] === RPCID)?.[2];
if (!reponseData) return {} as GoogleAlbum;
const parsedResponse = JSON.parse(reponseData);
const albumItems = parsedResponse?.[1] || [];
const next = parsedResponse?.[2];
const albumDetails = parsedResponse?.[3] || [];
const [albumId, albumTitle, albumDates, downloadUrl, albumCover, albumOwner] =
albumDetails;
const sharedUrl = albumDetails?.[32];
const createdAt = albumDates?.[4];
const updatedAt = albumDates?.[8];
const coverUrl = albumCover?.[0];
const coverWidth = albumCover?.[1];
const coverHeight = albumCover?.[2];
const ownerId = albumOwner?.[1];
const ownerDetails = albumOwner?.[11] || [];
const [ownerFullname, , ownerGenre, ownerName] = ownerDetails;
const ownerPhoto = albumOwner?.[3];
const items = albumItems.map((item: any[]) => {
const [id, itemData] = item;
const [url] = itemData as any[];
return { id, url };
});
return {
id: albumId,
key,
token: next,
title: albumTitle,
url: sharedUrl,
downloadUrl,
createdAt,
updatedAt,
cover: {
url: coverUrl,
width: coverWidth,
height: coverHeight,
},
owner: {
id: ownerId,
fullname: ownerFullname,
name: ownerName,
genre: ownerGenre,
photo: ownerPhoto,
},
items,
};
};
export const getAlbum = async (url: string): Promise<GoogleAlbum> => {
let data = null;
try {
const res = await apiClient.get(url);
data = res.data;
} catch (error: any) {
if (error?.response?.status === 404 || error?.code === "ERR_BAD_REQUEST") {
throw new AlbumNotFoundException();
}
throw error;
}
// Extract Metadata using Regex (No Cheerio)
const canonicalMatch = data.match(RE_CANONICAL);
const canonicalUrl = canonicalMatch ? canonicalMatch[1] : "";
if (!canonicalUrl) throw new AlbumNotFoundException();
const albumUrlObj = new URL(canonicalUrl);
const pathParts = albumUrlObj.pathname.split("/");
const id = pathParts[pathParts.length - 1] || pathParts[pathParts.length - 2]; // Handle trailing slash
// Key might be in the original URL or need to be extracted from response if redirected
// But usually it's in the query param of the canonical or initial URL.
// We'll trust the canonical for ID, but for key we check the URL params of the Input URL or the Canonical.
let key = albumUrlObj.searchParams.get("key");
if (!key) {
// Fallback: try to find key in the HTML if not in URL
// (The original code assumed it was in the canonical URL params)
// If the original logic worked with `albumUrl.searchParams.get('key')`, we stick to it.
// But let's check the input `url` too just in case.
try {
const inputUrlObj = new URL(url);
key = inputUrlObj.searchParams.get("key");
} catch {}
}
// Last resort regex if key is still missing (sometimes encoded in JS vars)
if (!key) {
const keyMatch = data.match(RE_KEY);
if (keyMatch) key = keyMatch[1];
}
if (!id || !key) throw new AlbumNotFoundException();
let album: GoogleAlbum | undefined;
let token: string | undefined;
let pageCount = 0;
const MAX_PAGES = 100; // Safety limit
do {
// Safety break
if (pageCount++ > MAX_PAGES) break;
const parts = await getData(id, key, token);
if (!album) {
// set once on first page
if (!parts.id) {
// If getData failed to parse on first try
throw new AlbumNotFoundException();
}
const { title, owner, cover, downloadUrl, createdAt, updatedAt } = parts;
album = {
key,
id,
url,
title,
token,
owner,
cover,
downloadUrl,
createdAt,
updatedAt,
items: [],
};
}
token = parts.token;
if (parts.items) {
album.items.push(...parts.items);
}
} while (token);
return album!;
};