Deno でスクレイピングしてみる

かれこれ、最後の更新から約 2 カ月経ってしまった。
少々忙しかったり、記事にするほどでも無いような実験だったりしていた。

そのなかで、Deno でスクレイピングをやってみたので書き起こしておきたい。

参考

スクレイピング

「そもそもスクレイピングって何だろう」ということを確認しておきたい。

Wikipedia よりウェブスクレイピングの項目を参照すると、次の様に解説がある。

ウェブスクレイピング(英: Web scraping)とは、ウェブサイトから情報を抽出するコンピュータソフトウェア技術のこと。通常このようなソフトウェアプログラムは低レベルの HTTP を実装することで、もしくはウェブブラウザを埋め込むことによって、WWW のコンテンツを取得する。ウェブスクレイピングはユーザーが手動で行なうこともできるが、一般的にはボットやクローラ(英: Web crawler)を利用した自動化プロセスを指す。

「ウェブサイトから情報を抽出すること」を言うそうだ。
「網羅的に情報を走査していくこと」というのをイメージしていたが、別に 1 ページでも情報を抽出していれば、wikipedia の記載によれはスクレイピングであった。

作るもの

他所様の WEB サイトをスクレイピングしていくのは規約的な配慮が必要なので、自分のサイト(つまりココ)をスクレイピングし、リンク切れしている箇所を探すツールを作る。

スクレイピング結果は SQLite に保管する。

実装

ページのダウンロードするツール

単一の URL にアクセスして、結果の保存と結果を返すツール。
download() が本体であるもの、単一の URL でテストしたい。
特定の URL からダウンロードだけ行いたい。という需要が有った。
なので、このソースコードから実行した時には、cli ツールとして動作する。

single_download.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import { parse } from "std/flags/mod.ts";
import { z } from "zod";
import { insertDownloadLog } from "./db.ts";

export async function download(url: string, path: string) {
try {
const getResult = await fetch(url);
if (!getResult.ok) throw new Error("Fetch response error");
const html = await getResult.text();

Deno.writeTextFile(path, html);

insertDownloadLog(url, path);
return { html };
} catch (error) {
console.error(error);
}
return { html: undefined };
}

if (import.meta.main) {
const parsedArgs = parse(Deno.args);

console.log(parsedArgs);

const urlArg = Object.fromEntries(
Object.entries(parsedArgs).filter((p) => p[0] == "url" || p[0] == "path")
);
const paramsSchema = z.object({
url: z.string().url({ message: "Invalid url" }),
path: z.string().regex(/[\/0-9a-zA-Z]/),
});
const parseResult = paramsSchema.safeParse(urlArg);

if (!parseResult.success) {
console.error(
parseResult.error.issues
.map((k: { [key: string]: any }) => JSON.stringify(k))
.join("\n")
);
Deno.exit();
}

console.log(parseResult);

await download(parseResult.data.url, parseResult.data.path);
}

実行すると、次の様になります。

1
2
3
$ deno run --allow-net --allow-read --allow-write single_download.ts --url http://host.docker.internal:4507/blog/ --path ./download/blog
# ※docker コンテナで実行しているので、アクセス先のhostは、host.docker.internal にする。

別途ローカルで自身の WEB サイトをサーバーを立ち上げて配信させているので、こちらにアクセスする。
local で動かしているサイトへのスクレイピングなら、誰のご迷惑もかけずに済む。

ダウンロードのログを SQLite に書き込んでいるが、これらの処理は別の切り出している。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import { DB } from "https://deno.land/x/sqlite/mod.ts";

const db = new DB("scraping.db");

db.execute(`
CREATE TABLE IF NOT EXISTS download_logs (
url TEXT,
path TEXT,
created_at INTEGER
)
`);

db.execute(`
CREATE TABLE IF NOT EXISTS inspection_logs (
targetUrlPathName TEXT primary key,
isExist INTEGER,
created_at INTEGER
)
`);

export function insertDownloadLog(url: string, path: string) {
const timestamp = new Date().getTime();
db.query(
"INSERT INTO download_logs (url, path, created_at) VALUES (?, ?, ?)",
[url, path, timestamp]
);
}

export function insertInspectEntry(targetUrlPathName: string) {
const timestamp = new Date().getTime();
db.query(
`INSERT OR IGNORE INTO inspection_logs (targetUrlPathName, created_at) VALUES (?, ?)`,
[targetUrlPathName, timestamp]
);
}

export function updateInspectLog(targetUrlPathName: string, isExist: 1 | 0) {
const timestamp = new Date().getTime();
db.query(
"UPDATE inspection_logs SET isExist = ? where targetUrlPathName = ?",
[isExist, targetUrlPathName]
);
}

export function selectUrlNoInspectCount() {
const result = db.query(
"SELECT * FROM inspection_logs WHERE isExist is NULL"
);
return result.length;
}
export function selectUrlNoInspects() {
return db.query(
"SELECT targetUrlPathName FROM inspection_logs WHERE isExist is NULL"
);
}

export function selectNoExistLinks() {
return db.query(
"SELECT targetUrlPathName FROM inspection_logs WHERE isExist is 0"
);
}

export function closeDb() {
db.close();
}

読み込むとテーブルを作るようにしている少々雑な作りではある。
SQLite は時刻の型が無いので、created_at は INTEGER で定義した。
見てもピンとは来ないが、「時系列がわかればいい」位なら困らないと感じる。(使うときにはパースすればイイだけでもある)

HTML を検証をするツール

今度は、ソースにした HTML からリンクを取得して SQLite に保管するツール。
こちらは、このソースから実行すると保存済みの HTML ファイルを読み込んで解析し、SQLite にリンクを保存する。

single_inspect.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import { parse } from "std/flags/mod.ts";
import { z } from "zod";
import { insertInspectEntry } from "./db.ts";

import {
DOMParser,
Element,
NodeList,
} from "https://deno.land/x/deno_dom/deno-dom-wasm.ts";

export function inspect(html: string) {
const doc = new DOMParser().parseFromString(html, "text/html")!;
const elements = doc.querySelectorAll("a")!;

const anchors = new Set(
Array.from(elements)
.filter((e) => e.attributes[0].nodeName == "href")
.map((e) => e.attributes[0].value! as string)
.filter((url: string) => url.match(/^(\/)(?!.*\#)/))
);

anchors.forEach((url) => {
insertInspectEntry(url);
});
}

if (import.meta.main) {
const parsedArgs = parse(Deno.args);

console.log(parsedArgs);

const urlArg = Object.fromEntries(
Object.entries(parsedArgs).filter((p) => p[0] == "path")
);
const paramsSchema = z.object({
path: z.string().regex(/[\/0-9a-zA-Z]/),
});
const parseResult = paramsSchema.safeParse(urlArg);

if (!parseResult.success) {
console.error(
parseResult.error.issues
.map((k: { [key: string]: any }) => JSON.stringify(k))
.join("\n")
);
Deno.exit();
}

console.log(parseResult);

const html = Deno.readTextFileSync(parseResult.data.path);

await inspect(html);
}

実行すると次の様になります。

1
2
3
$ deno run --allow-net --allow-read --allow-write single_inspect.ts --path ./download/blog
{ _: [], path: "./download/blog" }
{ success: true, data: { path: "./download/blog" } }

保存したデータは、次の様に保存されている。

targetUrlPathName isExist created_at
/blog/ NULL 1675952912239
/blog/2022/12/17/js68/ NULL 1675952912252
/blog/2022/12/14/js67/ NULL 1675952912261
/blog/2022/11/27/js66/ NULL 1675952912270
/blog/2022/10/29/myservice2/ NULL 1675952912279
/blog/2022/10/23/js65/ NULL 1675952912288
/blog/2022/09/16/js64/ NULL 1675952912296
/blog/2022/09/16/js63/ NULL 1675952912304
/blog/2022/09/10/js62/ NULL 1675952912312
/blog/2022/09/03/js61/ NULL 1675952912320
/blog/2022/08/28/js60/ NULL 1675952912328
/blog/archives NULL 1675952912336
/blog/plans NULL 1675952912344

イイ感じ。
isExist は、targetUrlPathName の先が存在しているのかチェックして埋める。

ページを網羅的に走査して、リンク切れを探すツール

ダウンロードと解析ができたので、後は取得した URL を順番に全部同じ処理を繰り返していけば良い。

scraping.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import { parse } from "std/flags/mod.ts";
import { z } from "zod";
import {
closeDb,
insertInspectEntry,
selectNoExistLinks,
selectUrlNoInspects,
updateInspectLog,
} from "./db.ts";
import { download } from "./single_download.ts";
import { inspect } from "./single_inspect.ts";

const parsedArgs = parse(Deno.args);

const urlArg = Object.fromEntries(
Object.entries(parsedArgs).filter((p) => p[0] == "url")
);
const paramsSchema = z.object({
url: z.string().url({ message: "Invalid url" }),
});
const parseResult = paramsSchema.safeParse(urlArg);

if (!parseResult.success) {
console.error(
parseResult.error.issues
.map((k: { [key: string]: any }) => JSON.stringify(k))
.join("\n")
);
Deno.exit();
}

const url = new URL(parseResult.data.url);

insertInspectEntry(url.pathname);

while (selectUrlNoInspects() != 0) {
const urls = selectUrlNoInspects() as string[][];
console.log(urls);

for await (const pathName of urls) {
console.log(pathName);
const path = pathName[0].split("/").join("_");
console.log(`${url.protocol}//${url.host}${pathName}`);

const result = await download(
`${url.protocol}//${url.host}${pathName}`,
`download/${path}`
);
console.log(result);

if (!result.html) {
updateInspectLog(pathName[0], 0);
} else {
updateInspectLog(pathName[0], 1);
await inspect(result.html);
}
}
}

const links = selectNoExistLinks() as string[][];

console.log(`

** リンク切れ **
${links.map((l) => l[0]).join("\n")}

`);

closeDb();

実行すると次の様になります。

1
2
3
4
5
6
7
$ deno run --allow-net --allow-write --allow-read scraping.ts --url http://host.docker.internal:4507/blog/

# 出力省略

** リンク切れ **
/blog/2019/10/16/sequelize0/
/blog/2019/11/18/ruby18

リンクが切れている URL を 2 箇所も見つけてしまったので、後で直すことにする。


今回は、Deno でスクレイピングやってみました。
各種モジュールを使用しているものの、deno-dom を除けば特に必要なものもなくスクレイピングができました。

SSG された WEB ページのスクレイピングなので、これだけ簡単で有ることも事実ですが、Deno 向けに Puppeteer の fork されたモジュールも有ります。
こちらを使えばフロントでいろいろしているサイトもスクレイピングできます。

今回の実装物は、こちらで公開済みです。

では。