下記サイトの続き
4: srcフォルダとその中身の作成
*srcはsourceの略です。
*spec.tsと付いたファイルはテスト用ファイルです。
mkdir src;
touch src/main.ts;
# 型を指定するファイルの作成
mkdir @types;
touch tabs.d.ts
touch urls.d.ts
# -----------------------------------
# main.tsで使う部品の作成
# -----------------------------------
mkdir src/utils;
# terminalのコマンドをNode.js内で実行する処理を記述するファイル
touch src/utils/command.ts;
# エラーキャッチされていない非同期処理の処理を記述するファイル
touch src/utils/error-handle.ts;
# ファイルの読み書き(input/output)についての処理を記述するファイル
touch src/utils/file-io.spec.ts;
touch src/utils/file-io.ts;
# utilsフォルダのモジュール全てを楽にインポートするためのファイル
touch src/utils/index.ts;
# セレニウムの設定関数を記述しておくファイル
touch src/utils/settings.ts;
# 主にタブの遷移に関連するファイル
touch src/utils/tabs.spec.ts;
touch src/utils/tabs.ts;
# 開くURLを書いておくファイル
touch src/utils/urls.spec.ts;
touch src/utils/urls.ts;
ここをクリックで、この時点での画像を表示
command.ts
後ほど、バッチファイルを書いて、ダブルクリックするだけでWebサイトからコンテンツを取り込み、結果を見たいのでpauseコマンドを使用します。そのためにコマンドを実行できる処理を書きます。
*process.argvでターミナルでコマンドを実行したときのオプションとして渡した文字列が手に入ります。
例: ts-node src/main.ts --pause
process.argv[0] にはts-node
process.argv[1] にはsrc/main.ts
process.argv[2] には--pause
src/utils/command.ts
import { exec } from "child_process";
/**
* ターミナルのコマンドを実行する関数
* @param command - 実行するコマンド名
* @param args - option: コマンド実行のトリガーとなる引数
*
* @example:pauseコマンドを実行する
* // 1. main.tsの中で
* execCommand("pause", /[^(-{1})](-{2})?pause/);
*
* // 2. ターミナルにて以下のコマンドを実行
* npx ts-node src/main.ts --pause
* //または
* npx ts-node src/main.ts pause
*/
export const execCommand = (command: string, arg: string | RegExp = command): void => {
// arg引数が文字列だったらこちらの処理を行う
if (typeof arg === "string" && arg === process.argv[2]) return execute(command);
// arg引数が正規表現だったらこちら
if (arg == RegExp(arg) && arg.test(process.argv[2])) return execute(command);
};
/**
* シェルコマンドを実行する関数
* @param command 実行したいコマンド
*/
export const execute = (command: string): void => {
exec(command, (error) => console.error(`[ERROR] ${error}`));
};
file-io.ts
Webサイトからとってきた情報を保存しておきたいので、ファイルの書き込みについての関数を作ります。
src/utils/file-io.ts
import { mkdir, writeFile } from "fs/promises";
// import { promises as fsp } from "fs"; // Node.js 10.17 ~ 12をお使いの場合 (fsp.writeFileのように使います)
import chalk from "chalk";
/**
* 文字列、文字列の配列をファイルに書き込む関数
* @param path - 書き込みたいファイル名
* @example `./src/${moment().format("YYYY-MM-DD")}.txt`
* @param contents - 書き込む内容
*/
export const writeFiles = async (path: string, contents: string | string[]): Promise<void> => {
try {
// ディレクトリが存在しない場合、作成します
const dir_path = path.replace(/(?:[^/]+?)??$/, "");
await mkdir(dir_path, { recursive: true });
// ファイルへ書き込み
await writeFile(path, stripAnsi(`${contents}`));
console.log(chalk`{green 書き込みに成功しました}`);
return;
// error catch
} catch (error) {
console.log(chalk`{red 書き込みに失敗しました}\n${error}`);
throw error;
}
};
/**
* chalkで付けたカラーコードを削除する関数
* @param text
*/
export const stripAnsi = (text: string): string =>
text.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, "");
index.ts
利便性の観点から、utilsフォルダを選択したらそのフォルダに含まれるすべての関数を取り出せるようにします。
src/utils/index.ts
export * from "./settings";
export * from "./urls";
export * from "./tabs";
export * from "./command";
export * from "./file-io";
error-handle.ts
*chalk: 色を付ける
*process.on(イベントのタイプ, イベントが起きたら実行する関数)
src/utils/error-handle.ts
import chalk from "chalk";
// イベントリスナーと呼ばれる類のものです。ある特定の条件がそろうと、第2引数の関数が実行されます。
process.on("unhandledRejection", (error, promise) => {
console.error(chalk`Error: {red ERR!} ${error}`);
console.error(chalk`promise: {red ERR!} ${promise}`);
process.exit(1); // exitコード1はエラーが起きて終了、0は正常に終了
});
setting.ts
このファイルに、セレニウムでChromeブラウザを起動する処理を書きます。
*定型文のようなものなので、記述の仕方は覚えず、どのようなオプション(headlessなど)があるのかだけ、頭に入れておきます。
*コメント内の@paramのようなコメントをJSDocと呼ばれるアノテーション(注釈)を記述するマークアップ言語です。
*import {} from "module":
分割代入を利用したインポート方法で、
import { Builder, ThenableWebDriver } from "selenium-webdriver";の場合は、
selenium-webdriverのindexというモジュールから、BuilderとThenableWebDriverを取り寄せるといった意味です。
src/utils/setting.ts
import "chromedriver";
import { Builder, ThenableWebDriver } from "selenium-webdriver";
import chrome from "selenium-webdriver/chrome";
// utils
import "./error-handle";
/**
* @param options
* @example
* const driver = build([
* "--headless", // ブラウザを不可視状態のバックグラウンド実行。
* "--disable-gpu", // ヘッドレスモードでの実行の際に必要なオプション (じきに不要に。)
* "--disable-extensions", // 拡張機能の無効化
* "--no-sandbox",
* `--window-size=1980,1200`,
* ]);
*/
export const build = (options: string[] = []): ThenableWebDriver => {
const chromeOptions = new chrome.Options().addArguments(...options);
return new Builder().setChromeOptions(chromeOptions).forBrowser("chrome").build();
};
tabs.ts
TODO: コードの解説
src/utils/tabs.ts
import { ThenableWebDriver } from "selenium-webdriver";
/**
* タブを新規作成する関数
* @param url - URLを文字列で入れる
* @param driver - buildしたdriverを入れる
*/
export const createNewTab = async (url: string, driver: ThenableWebDriver): Promise<void> => {
try {
await driver.executeScript("window.open(arguments[0], '_blank')", url);
} catch (error) {
console.error(error);
throw new Error("新規タブ作成に失敗しました");
}
};
/**
* タブを切り替える関数
* @param count - 切り替えたいタブは何番目かを入れる
* @param driver - buildしたdriverを入れる
*/
export const switchNewTab = async (count: number, driver: ThenableWebDriver): Promise<void> => {
try {
const tabs = await driver.getAllWindowHandles();
await driver.switchTo().window(tabs[count + 1]);
} catch (error) {
console.error(error);
throw new Error("タブの切り替えに失敗しました");
}
};
urls.ts
TODO: コードの解説
src/utils/urls.ts
import chalk from "chalk";
// utils
import type { ScrapingFunc } from "../@types/urls";
export const default_urls = [
"https://www.google.com/",
"https://www.google.com/",
"https://www.google.com/",
"https://www.google.com/",
"https://www.google.com/",
"https://www.google.com/",
];
/**
* Webサイトから情報を取得するための関数
*
* @param domain - 例: "google.com"
* @param url - 例: "https://www.google.com/"
* @param getElement1 - Webサイトの要素を手に入れる1つめの関数
* @param getElement2 - Webサイトの要素を手に入れる2つめの関数
* @param driver - 例: `const driver = build()`
*/
export const getUrlContent: ScrapingFunc<string, string> = async ({
url = "https://www.google.com/",
domain = "google.com",
getElement1,
getElement2,
driver,
}) => {
// isURL?
if (RegExp(`^https?://.*${domain}.*`).test(url)) {
try {
// サイトのタイトルを取得
const title = await driver.getTitle();
// サイトの要素を手に入れる関数の結果1つ目
const element1 = await getElement1(driver);
// サイトの要素を手に入れる関数の結果2つ目
const element2 = await getElement2(driver);
// 結果を返します
return chalk`
サイトタイトル: {yellow ${title}}
取れた要素1: {cyan ${element1}}
取れた要素2: {green ${element2}}
`;
// Catch error
} catch (e) {
const error_log = chalk`{red ${url} の要素取得に失敗しました}`;
console.log(error_log);
console.log(e.message);
}
}
};
urls.d.ts
*複合的な任意の型を指定できます。直接コードを記述もできますが、オブジェクトのプロパティが増大すると、記述が冗長と化し、可読性が下がる(読みづらい)ので、名前を付けて使いまわします。
*TやUについて:
ジェネリックと呼ばれるものです。いわば型定義版の変数です。関数を実行するときに指定して決めます。
TやUには、stringやundefinedといったものが入ります。
type 任意の名前<ジェネリック> = {
key:型,
}
src/utils/@types/urls.d.ts
import { ThenableWebDriver } from "selenium-webdriver";
export type ScrapingKit<T, U> = {
domain?: string;
url?: string;
getElement1: (driver: ThenableWebDriver) => Promise<T>;
getElement2: (driver: ThenableWebDriver) => Promise<U>;
driver: ThenableWebDriver;
};
export type ScrapingFunc<T, U> = (content: ScrapingKit<T, U>) => Promise<string | undefined>;
5: サンプルとしてアメリカ合衆国・アリゾナ州の天気情報を取得する関数を記述、その後実行
TODO: コードの解説
どのようにして要素を取得して目的のものを得るか
最も簡単だと思われるのは、xpathを利用した取得です。以下の手順で取得します。
1.取得したい情報のサイトへ赴きます。
今回は以下のサイト
ここをクリックで、今回取得するサイトの画像を表示
2.サイトへ赴いたら欲しい情報をマウスで選択した状態で、
F12または、Ctrl + Shift + I、
もしくは「右クリック→検証というメニュー」を押し、そのサイトのHTMLを表示させます。
ここをクリックで、要素を選択した状態の画像を表示
3.HTMLのコードが出たら、選択した要素が青線で表示されるので、そこを右クリックし、Copy、Copy Full Xpathというものを選択します。
ここをクリックで、DevToolの画面(検証の画面)を表示
4.By.Xpathという関数内のかっこにコピーした値を入れます。
driver.findElement(By.xpath("<ここに入れる>")).getText()
入れた後。
driver.findElement(By.xpath("/html/body/div[7]/div/div[9]/div[1]/div/div[2]/div[2]/div/div/div[1]/div/div/div/div[2]/span/div[1]")).getText()
これは、driverがfindElement関数の中で指定された要素を取得し、getText()で取得した要素の中のテキストを取得しています。
*インポートの部分を変更、型を追加インポートし、getArizonaWeatherFromGoogleという関数を追加定義しています
*Partialというタイプを使うことで、必須項目として型定義したものを、必須ではないとすることが出来ます
*引数をオブジェクトにすることで、指定したい引数を楽に選択可能にします。
src/utils/urls.ts
import chalk from "chalk";
import moment from "moment";
import { By } from "selenium-webdriver";
// utils
import { build, execCommand, writeFiles } from ".";
import type { ScrapingContent, ScrapingFunc } from "../@types/urls";
export const getUrlContent: ScrapingFunc<string, string> = async ({
url = "https://www.google.com/",
domain = "google.com",
getElement1,
getElement2,
driver,
}) =>
// 記載を省略
};
/**
* google検索から、「アメリカ合衆国・アリゾナ州」の天気を取得し、ファイルに書き込んでくれるお試し関数
*
* @param url @default "https://www.google.com/search?q=arizona+weather&gl=us&hl=en&pws=0&gws_rd=cr"
*
* 他地域用のURLを取得するためのクエリ: アメリカの場合は URL + "&gl=us&hl=en&pws=0&gws_rd=cr"
*
* @param sleepMs 読み込み待機時間。単位:ミリ秒 - @default 5000
* @param writeLogPath 書き込むファイルパス - @default `src/selenium/logs/${today}.txt`
*/
export const getArizonaWeatherFromGoogle = async ({
url = "https://www.google.com/search?q=arizona+weather&gl=us&hl=en&pws=0&gws_rd=cr",
sleepMs = 3000,
writeLogPath = `src/logs/${moment().format("YYYY-MM-DD")}.txt`,
buildOptions,
}: Partial<ScrapingContent> = {}): Promise<string | undefined> => {
// setting
const driver = build(buildOptions);
try {
// URLを開きます
await driver.get(url);
await driver.sleep(sleepMs);
const log = await getUrlContent({
url,
// URLが指定したドメインに引っかかるかテストします
domain: "google.com/search",
// 1つめのHTML要素の取得関数を記述
getElement2: async () => {
// 今日の曜日 (例: Monday)
const dow = await driver
.findElement(
By.xpath("/html/body/div[7]/div/div[9]/div[1]/div/div[2]/div[2]/div/div/div[1]/div/div/div/div[2]/span/div[2]"),
)
.getText();
const today = moment().format("YYYY-MM-DD"); // 日付 (ex.2021-6-23)
return `本日の日付: ${today} ${dow}`;
},
// 2つめのHTML要素の取得関数を記述
getElement1: async () => {
// 気温: °C(例: 27)
const celsius = await driver.findElement(By.id("wob_tm")).getText();
// 降水確率: (例: 60%)
const pp = await driver.findElement(By.id("wob_pp")).getText();
// 天気:(例: Light rain showers)
const weather = await driver.findElement(By.id("wob_dc")).getText();
return `
気温: ${celsius}°C
降水確率: ${pp}
天気: ${weather}
`;
},
driver,
});
if (log) {
console.log(
chalk`{green ---------------- 結果 -------------------}
${log}`,
);
// 結果が取れているかどうか
// 取れていたらファイルに書き込みます
await writeFiles(writeLogPath, log);
return log;
}
// 取れずに終わったとき
console.error(chalk`{red 値が取得できませんでした。取得した値は ${log} です}`);
// エラー処理
} catch (error) {
console.error(error);
throw new Error("googleサイトからデータ取得に失敗しました");
// エラーまたは正常終了で実行される
} finally {
await driver.quit();
execCommand("pause");
}
};
urls.d.tsに追記
src/utils/@types/urls.d.ts
import { ThenableWebDriver } from "selenium-webdriver";
export type ScrapingKit<T, U> = {
domain?: string;
url?: string;
getElement1: (driver: ThenableWebDriver) => Promise<T>;
getElement2: (driver: ThenableWebDriver) => Promise<U>;
driver: ThenableWebDriver;
export type ScrapingFunc<T, U> = (content: ScrapingKit<T, U>) => Promise<string | undefined>;
+
+ export type ScrapingContent = {
+ url: string;
+ sleepMs: number;
+ writeLogPath: string;
+ buildOptions: string[];
+ };
main.ts
src/main.ts
import { getArizonaWeatherFromGoogle } from "./utils";
// sample
getArizonaWeatherFromGoogle({
buildOptions: [
"--headless",
"--disable-gpu",
// "--no-sandbox",
`--window-size=1980,1200`,
],
}).catch((error: Error): void => {
console.log(error.message);
process.exit(1);
});
実行
以下のコマンドを実行します。
*なお、VS-Codeの左下に「NPMスクリプト」という項目があり、
そこにはpacakge.jsonのscriptに登録した項目を簡単に実行できるようになっています。
*虫のアイコンはデバッグ用。右矢印のアイコンは実行です。
ここをクリックで、NPMスクリプトの拡大画像を表示
npm run selenium
# または
npx ts-node src/main.ts
# yarnをお使いの場合
yarn run selenium
無事に実行出来たら、以下のような結果を得られるはずです。
お疲れさまでした。
6:テストコード
TODO コード解説
*describe、context,itはグループとしての区切りを表す関数です。それぞれ第1引数にテスト内容、第2引数には関数を入れます。
*beforeAllは、グループ内のテストが実行される前に実行され、afterAllはテスト実行後に実行されます。
jest.config.jsを記述し、設定します
jest.config.js
// For a detailed explanation regarding each configuration property, visit:
// https://jestjs.io/docs/en/configuration.html
module.exports = {
// Automatically clear mock calls and instances between every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
collectCoverage: true,
// The directory where Jest should output its coverage files
coverageDirectory: "coverage",
// A list of reporter names that Jest uses when writing coverage reports
coverageReporters: [
"html",
// "json",
"text",
"lcov",
// "clover"
],
// A preset that is used as a base for Jest's configuration
preset: "ts-jest",
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
setupFiles: ["jest-plugin-context/setup"],
// The test environment that will be used for testing
testEnvironment: "node",
// The glob patterns Jest uses to detect test files
testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)",
"**/src/**/?(*.)+(spec|test).[tj]s?(x)",
],
};
file-io.spec.ts
src/utils/file-io.spec.ts
import moment from "moment";
import { promises as fsp } from "fs";
import { writeFiles } from "./file-io";
describe("file-io", () => {
it("should remove the color code", async () => {
// 必要な情報を作成
const today = moment().format("YYYY-MM-DD");
const path = `src/test/${today}-file-io.txt`;
const dir = path.replace(/(?:[^/]+?)?(?:-test)?$/, "");
try {
await writeFiles(
path,
// カラーコードを付けたHelloWorldという文字列を書き込む
`
\u001b[30mH \u001b[31me \u001b[32ml \u001b[33ml \u001b[0mo
\u001b[34mW \u001b[35mo \u001b[36mr \u001b[37ml \u001b[0md
`,
);
// 書いたものを読み込む
const result = (await fsp.readFile(path, "utf-8")).replace(/\s*/g, "");
return expect(result).toBe("HelloWorld");
// error catch
} catch (error) {
return expect(error).toMatch("error");
// テストに使用したものを削除
} finally {
await fsp.unlink(path);
await fsp.rmdir(dir);
}
});
});
tabs.spec.ts
src/utils/tabs.spec.ts
import { ThenableWebDriver } from "selenium-webdriver";
import { build, createNewTab, switchNewTab } from ".";
let driver: ThenableWebDriver;
describe("tabs", () => {
context("when it works fine", () => {
beforeAll(() => {
driver = build(["--headless", "--disable-gpu", "--window-size=1024,768"]);
});
afterAll(async () => await driver.quit());
it("should be able to create and switch tabs", async () => {
try {
const url = "https://www.google.com/";
// URLを開きます
await driver.get(url);
// 新規タブを作成
await createNewTab(url, driver);
// 先ほど作ったタブに切り替えます
await switchNewTab(0, driver);
// 現在フォーカスが当たっているタブのサイトタイトルを取得します
const title = await driver.getTitle();
// 取得したタイトルは、"Google"という文字列かどうか?
expect(title).toBe("Google");
} catch (e) {
return expect(e).toMatch("error");
}
}, 30000);
});
});
urls.spec.ts
src/utils/urls.spec.ts
import { promises as fsp } from "fs";
import moment from "moment";
// utils
import { getArizonaWeatherFromGoogle } from "./urls";
describe("getArizonaWeatherFromGoogle", () => {
it("should be able to get the date", async () => {
// テストに必要な情報を作成します
const today = moment().format("YYYY-MM-DD");
const path = `src/test/${today}-test.txt`;
const dir = path.replace(/(?:[^\\/]+?)?$/, "");
try {
// テストしたい関数の実行
const log = await getArizonaWeatherFromGoogle({
writeLogPath: `src/test/${today}-test.txt`,
});
//"タイトル(何かの文字)取れた要素1(何かの文字)取れた要素2(何かの文字)"として文字列がとれたかどうか?
return expect(log).toMatch(/タイトル:[\s\S]*取れた要素1:[\s\S]*取れた要素2:/g);
// catch error
} catch (e) {
return expect(e).toMatch("error");
// テスト時に作成したファイルの削除
} finally {
await fsp.unlink(path);
await fsp.rmdir(dir);
}
}, 30000);
});
以下のコマンドを実行
npm run test
# Yarn使用時は
yarn run test
ここをクリックで、テスト実行時の結果の画像を表示
7:あとがき
今回の執筆にあたって、なるべく丁寧に解説できるよう心掛けましたが、コードの解説が予想以上に膨大になってしまいました。
そのため、コードはgitHubのサンプルコードの中にマークダウン(md拡張子のファイル)または、別フォルダにtsファイル形式で書いておく予定です。
また、今回の記事で、少しでもプログラミングの楽しさを知っていただけたら幸いです。
見づらい文章校正で大変恐縮ではございますが、ありがとうございました。
おまけ
TODO: bashを書いて実行
バッチファイルを書いて実行 (windows10)
ダブルクリックで実行できるようにします
selenium.bat
@echo off
rem pushdコマンドでNode.jsのセレニウムプロジェクトのパスまで移動します
pushd D:\javascript\selenium-playground
rem コマンドを実行します。結果を確認したいのですが、`npm run selenium`だけですと、すぐにプロンプトが閉じてしまいます。
rem そのため、`pause`引数を渡します。
npm run selenium pause
以下のフォルダにバッチファイルを置けば、PC起動時に自動実行されます
C:/Users/<あなたのPCのユーザー名>/AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup
ここをクリックで、cmdでの実行時の結果の画像を表示
属性の値を取得したい
urls.tsの例を使います
<div class="wob_dts" id="wob_dts">Monday 2:00 AM</div>
getText()の部分を、getAttribute("id")に変更します。
driver.findElement(By.xpath("/html/body/div[7]/div/div[9]/div[1]/div/div[2]/div[2]/div/div/div[1]/div/div/div/div[2]/span/div[1]")).getAttribute("id");
期待される結果
- この画像のようにwob_dtsという値が取れます
↧