Closed20

Google Drive API を使って共有ドライブ内の情報を可視化したい

shingo.sasakishingo.sasaki

ゴール

  • Google Drive 上の特定フォルダ内にあるファイル及びそのメタデータをスクリプトから抽出したい
    • 特定フォルダ内には複数のフォルダがネストされている
    • ただしフォルダはマイドライブでなく、共有ドライブに存在する
  • 取得したいデータ
    • 親フォルダ名
    • ファイル名
    • 作成者名
    • ファイルへのURL

おそらく Google Drive API ですぐに実現可能なのでやってみる

shingo.sasakishingo.sasaki

Overview で確認できたこと

  • OAuth による My Drive / Shared drives へのアクセスが可能
  • すごいいろいろなことが出来るけど、 Search for files adn folders ができれば十分そう
  • Files リソースを見る限り、ファイル名・フォルダ名・URLは取れそう。共有ドライブの場合の作成者は怪しい(?)

Files リソースの詳しい中身は実際に取得してみればわかるか。

shingo.sasakishingo.sasaki

API アクセストークンを得るための Credentials の生成が必須

https://developers.google.com/workspace/guides/create-credentials

Credential にはいくつかの種類があるが、今回は自身がアクセス可能な共有ドライブ内のファイルへのアクセスが欲しいので、 OAuth client ID が必要そう。

アプリケーションタイプは Node の場合、 Desktop app で良さそうなので、以下手順で取得する。

  • Google Cloud Console
  • GCP プロジェクトを用意するか選択しておく
  • -> API とサービスメニュー
  • -> 認証情報
  • -> 認証情報を作成
  • -> OAuth クライアント ID
  • デスクトップアプリを選択する
  • 適当な名前を入力して作成
  • JSON をダウンロード
shingo.sasakishingo.sasaki

ダウンロードした Credential の JSON ファイルを credentials.json の名前で移動し、以下のサンプルコードを実行する。

const fs = require("fs");
const readline = require("readline");
const { google } = require("googleapis");

// If modifying these scopes, delete token.json.
const SCOPES = ["https://www.googleapis.com/auth/drive.metadata.readonly"];
// The file token.json stores the user's access and refresh tokens, and is
// created automatically when the authorization flow completes for the first
// time.
const TOKEN_PATH = "token.json";

// Load client secrets from a local file.
fs.readFile("credentials.json", (err, content) => {
  if (err) return console.log("Error loading client secret file:", err);
  // Authorize a client with credentials, then call the Google Drive API.
  authorize(JSON.parse(content), listFiles);
});

/**
 * Create an OAuth2 client with the given credentials, and then execute the
 * given callback function.
 * @param {Object} credentials The authorization client credentials.
 * @param {function} callback The callback to call with the authorized client.
 */
function authorize(credentials, callback) {
  const { client_secret, client_id, redirect_uris } = credentials.installed;
  const oAuth2Client = new google.auth.OAuth2(
    client_id,
    client_secret,
    redirect_uris[0]
  );

  // Check if we have previously stored a token.
  fs.readFile(TOKEN_PATH, (err, token) => {
    if (err) return getAccessToken(oAuth2Client, callback);
    oAuth2Client.setCredentials(JSON.parse(token));
    callback(oAuth2Client);
  });
}

/**
 * Get and store new token after prompting for user authorization, and then
 * execute the given callback with the authorized OAuth2 client.
 * @param {google.auth.OAuth2} oAuth2Client The OAuth2 client to get token for.
 * @param {getEventsCallback} callback The callback for the authorized client.
 */
function getAccessToken(oAuth2Client, callback) {
  const authUrl = oAuth2Client.generateAuthUrl({
    access_type: "offline",
    scope: SCOPES,
  });
  console.log("Authorize this app by visiting this url:", authUrl);
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });
  rl.question("Enter the code from that page here: ", (code) => {
    rl.close();
    oAuth2Client.getToken(code, (err, token) => {
      if (err) return console.error("Error retrieving access token", err);
      oAuth2Client.setCredentials(token);
      // Store the token to disk for later program executions
      fs.writeFile(TOKEN_PATH, JSON.stringify(token), (err) => {
        if (err) return console.error(err);
        console.log("Token stored to", TOKEN_PATH);
      });
      callback(oAuth2Client);
    });
  });
}

/**
 * Lists the names and IDs of up to 10 files.
 * @param {google.auth.OAuth2} auth An authorized OAuth2 client.
 */
function listFiles(auth) {
  const drive = google.drive({ version: "v3", auth });
  drive.files.list(
    {
      pageSize: 10,
      fields: "nextPageToken, files(id, name)",
    },
    (err, res) => {
      if (err) return console.log("The API returned an error: " + err);
      const files = res.data.files;
      if (files.length) {
        console.log("Files:");
        files.map((file) => {
          console.log(`${file.name} (${file.id})`);
        });
      } else {
        console.log("No files found.");
      }
    }
  );
}
shingo.sasakishingo.sasaki

実行して認可しようとしたらエラーが出た。

The API returned an error: Error: Access Not Configured. Drive API has not been used in project xxxxxxxxxx before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/drive.googleapis.com/overview?project=xxxxxxxxxx then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.

GCP プロジェクト側で、 Google Drive API の使用を許可する必要があるらしい。提示されたリンクに従って API を有効化する。

shingo.sasakishingo.sasaki

有効化後に再実行したところ、無事にファイル一覧っぽい標準出力が得られた。

サンプルコードでいうこのあたりの実行に成功したことがわかる。

function listFiles(auth) {
  const drive = google.drive({ version: "v3", auth });
  drive.files.list(
    {
      pageSize: 10,
      fields: "nextPageToken, files(id, name)",
    },
    (err, res) => {
      if (err) return console.log("The API returned an error: " + err);
      const files = res.data.files;
      if (files.length) {
        console.log("Files:");
        files.map((file) => {
          console.log(`${file.name} (${file.id})`);
        });
      } else {
        console.log("No files found.");
      }
    }
  );
}
shingo.sasakishingo.sasaki

drive.files.list 関数が、ドライブ内のファイルを一覧するための関数のようなので、これを使って以下を実現したい

  • 指定したフォルダ以下のファイル全て
  • 以下の情報を含む
    • ファイル名
    • 作成者名(無理なら最終更新者名)
    • ファイルURL
    • サムネイルURL

このあたりを参照すれば良さそう

https://developers.google.com/drive/api/v3/search-files

shingo.sasakishingo.sasaki

まず対象のフォルダ一覧が必要だったけど、以下のようなパラメータで取得できることを確認

    {
      includeTeamDriveItems: true,
      supportsTeamDrives: true,
      q: 'mimeType = "application/vnd.google-apps.folder" and name contains "欲しいフォルダ名のプレフィックス"',
      pageSize: 100,
      fields: "nextPageToken, files(id, name)",
    },

共有ドライブを対象にする設定がなかなか見つけられなくて苦労した。

shingo.sasakishingo.sasaki

サンプルコードはコールバック関数使ってたけど、 Promise でいけそうなので、対象フォルダの ID リストを取得するように改善

  drive.files
    .list({
      includeTeamDriveItems: true,
      supportsTeamDrives: true,
      q: 'mimeType = "application/vnd.google-apps.folder" and name contains "欲しいフォルダのプレフィックス"',
      pageSize: 100,
      fields: "files(id)",
    })
    .then((response) => {
      const files = response.data.files;
      return files.map((file) => file.id);
    })
    .then((folderIds) => {
      console.log(folderIds);
    });
shingo.sasakishingo.sasaki

folderIds が取得できてる状態で、これで対象フォルダ内のファイル一覧が取れるようになった。

  const files = await drive.files
    .list({
      includeTeamDriveItems: true,
      supportsTeamDrives: true,
      corpora: "allDrives",
      q: folderIds.map((id) => `'${id}' in parents`).join(" or "),
      pageSize: 999,
      fields: "files(id, name)",
    })
    .then((response) => {
      const files = response.data.files;
      console.log(files);
      console.log(files.length);
    });
shingo.sasakishingo.sasaki

fields: "files(*)" って書くと、ファイル内の全ての情報が取得できるので、ここから欲しい情報だけ抜き脱していく。

  • id: ユニーク文字列
  • name: ファイル名
  • parents: 親フォルダID. ID しか手に入らないので、名前との関連付けは別途必要。今回は常に1フォルダと仮定する
  • webViewLink: ブラウザで閲覧するためのリンク
  • iconLink: ファイルアイコンのリンク
  • lastModifyingUser: 最終更新者。作成者情報がないんだけどマジか
shingo.sasakishingo.sasaki

最終形。ファイル作成者情報が取れてないのが納得感ないけど、今回の目的だと基本的には作成者=最終更新者になるから一旦妥協しちゃう。

async function listFiles(auth) {
  const drive = google.drive({ version: "v3", auth });
  const folderIds = await drive.files
    .list({
      includeTeamDriveItems: true,
      supportsTeamDrives: true,
      q: 'mimeType = "application/vnd.google-apps.folder" and name contains "フォルダ名"',
      pageSize: 100,
      fields: "files(id)",
    })
    .then((response) => {
      const files = response.data.files;
      return files.map((file) => file.id);
    });
  const files = await drive.files
    .list({
      includeTeamDriveItems: true,
      supportsTeamDrives: true,
      corpora: "allDrives",
      q: folderIds.map((id) => `'${id}' in parents`).join(" or "),
      pageSize: 1,
      fields:
        "files(id, name, parents, webViewLink, iconLink, lastModifyingUser)",
    })
    .then((response) => {
      const files = response.data.files;
      console.log(files);
    });
}
shingo.sasakishingo.sasaki

あとはコードやデータ整形したり、取得したデータを使って遊ぶ段階に入る。
Google Drive API 自体は完全に理解したのでクローズ

shingo.sasakishingo.sasaki

ここまで、「ファイルの最終更新者」=「ファイルの作成者」って前提でいいや〜って進めてたけど、全然そんなことはなかったので、ちゃんと「ファイルの最新更新者」を取得したいので再開。

shingo.sasakishingo.sasaki

Nullable は雑に握りつぶしてるけど、こんな感じで取れた。

export function fetchFileCreatorInfo(drive: drive_v3.Drive, fileId: string) {
  return drive.revisions
    .list({
      fileId,
      fields: "revisions(modifiedTime,lastModifyingUser)",
    })
    .then((response) => {
      const revisions = response.data.revisions!.sort((a, b) => {
        return new Date(a.modifiedTime!) < new Date(b.modifiedTime!) ? -1 : 1;
      });
      return revisions[0].lastModifyingUser;
    });
}
このスクラップは2022/02/20にクローズされました