web-reader
z-ai-web-dev-SDKを使用してWebページのコンテンツ抽出機能を実装します。ユーザーがWebページのスクレイピング、記事コンテンツの抽出、ページメタデータの取得、またはWebコンテンツを処理するアプリケーション構築が必要な場合に活用してください。タイトル、HTML、公開日時の自動抽出に対応しています。
description の原文を見る
Implement web page content extraction capabilities using the z-ai-web-dev-sdk. Use this skill when the user needs to scrape web pages, extract article content, retrieve page metadata, or build applications that process web content. Supports automatic content extraction with title, HTML, and publication time retrieval.
SKILL.md 本文
Web Reader スキル
このスキルは、z-ai-web-dev-sdk パッケージを使用した Web ページ読み取りとコンテンツ抽出機能の実装ガイドです。アプリケーションが Web ページコンテンツをプログラムで取得して処理できるようにします。
スキルパス
スキル場所: {project_path}/skills/web-reader
スキルはプロジェクト内の上記パスに配置されています。
参照スクリプト: テスト用スクリプトは {Skill Location}/scripts/ ディレクトリで利用可能です。動作する例については {Skill Location}/scripts/web-reader.ts を参照してください。
概要
Web Reader では、Web ページからコンテンツを抽出し、記事メタデータを取得し、HTML コンテンツを処理するアプリケーションを構築できます。API は自動的にコンテンツを抽出し、任意の Web URL からクリーンで構造化されたデータを提供します。
重要: z-ai-web-dev-sdk はバックエンドコードでのみ使用する必要があります。クライアント側のコードでは決して使用しないでください。
前提条件
z-ai-web-dev-sdk パッケージはすでにインストール済みです。以下の例に示すようにインポートしてください。
CLI 使用法(シンプルなタスク用)
シンプルな Web ページコンテンツ抽出では、コードを書く代わりに z-ai CLI を使用できます。これは、簡単なコンテンツスクレイピング、URL テスト、または単純な自動化タスクに最適です。
基本的なページ読み取り
# Web ページからコンテンツを抽出
z-ai function --name "page_reader" --args '{"url": "https://example.com"}'
# 短いオプションを使用
z-ai function -n page_reader -a '{"url": "https://www.example.com/article"}'
ページコンテンツを保存
# 抽出したコンテンツを JSON ファイルに保存
z-ai function \
-n page_reader \
-a '{"url": "https://news.example.com/article"}' \
-o page_content.json
# ブログ記事を抽出して保存
z-ai function \
-n page_reader \
-a '{"url": "https://blog.example.com/post/123"}' \
-o blog_post.json
一般的な使用例
# ニュース記事を抽出
z-ai function \
-n page_reader \
-a '{"url": "https://news.site.com/breaking-news"}' \
-o news.json
# ドキュメントページを読み取り
z-ai function \
-n page_reader \
-a '{"url": "https://docs.example.com/getting-started"}' \
-o docs.json
# ブログコンテンツをスクレイプ
z-ai function \
-n page_reader \
-a '{"url": "https://techblog.com/ai-trends-2024"}' \
-o blog.json
# 研究論文を抽出
z-ai function \
-n page_reader \
-a '{"url": "https://research.org/papers/quantum-computing"}' \
-o research.json
CLI パラメータ
--name, -n: 必須 - 関数名("page_reader" を使用)--args, -a: 必須 - 以下を含む JSON 引数オブジェクト:url(文字列, 必須): 読み取る Web ページの URL
--output, -o <path>: オプション - 出力ファイルパス(JSON 形式)
レスポンス構造
CLI は以下を含む JSON オブジェクトを返します:
title: ページタイトルhtml: メインコンテンツ HTMLtext: プレーンテキストコンテンツpublish_time: 公開タイムスタンプ(利用可能な場合)url: 元の URLmetadata: 追加のページメタデータ
レスポンス例
{
"title": "機械学習入門",
"html": "<article><h1>機械学習入門</h1><p>機械学習は...</p></article>",
"text": "機械学習入門\n\n機械学習は...",
"publish_time": "2024-01-15T10:30:00Z",
"url": "https://example.com/ml-intro",
"metadata": {
"author": "太郎 田中",
"description": "機械学習の包括的なガイド"
}
}
複数の URL を処理
# 複数の URL を処理するシンプルなスクリプト
for url in \
"https://site1.com/article1" \
"https://site2.com/article2" \
"https://site3.com/article3"
do
filename=$(echo $url | md5sum | cut -d' ' -f1)
z-ai function -n page_reader -a "{\"url\": \"$url\"}" -o "${filename}.json"
done
CLI と SDK の使い分け
CLI を使用する場合:
- 簡単なコンテンツ抽出
- URL アクセシビリティのテスト
- シンプルな Web スクレイピングタスク
- 単発のコンテンツ取得
SDK を使用する場合:
- カスタムロジックを伴うバッチ URL 処理
- Web アプリケーションとの統合
- 複雑なコンテンツ処理パイプライン
- エラーハンドリングを備えた本番アプリケーション
仕組み
Web Reader は page_reader 関数を使用して:
- Web ページコンテンツを取得
- メイン記事コンテンツとメタデータを抽出
- HTML を解析してクリーニング
- タイトル、コンテンツ、公開日時を含む構造化データを返す
基本的な Web 読み取り実装
シンプルなページ読み取り
import ZAI from 'z-ai-web-dev-sdk';
async function readWebPage(url) {
try {
const zai = await ZAI.create();
const result = await zai.functions.invoke('page_reader', {
url: url
});
console.log('タイトル:', result.data.title);
console.log('URL:', result.data.url);
console.log('公開日:', result.data.publishedTime);
console.log('HTML コンテンツ:', result.data.html);
console.log('使用トークン:', result.data.usage.tokens);
return result.data;
} catch (error) {
console.error('ページ読み取り失敗:', error.message);
throw error;
}
}
// 使用例
const pageData = await readWebPage('https://example.com/article');
console.log('ページタイトル:', pageData.title);
記事テキストのみを抽出
import ZAI from 'z-ai-web-dev-sdk';
async function extractArticleText(url) {
const zai = await ZAI.create();
const result = await zai.functions.invoke('page_reader', {
url: url
});
// HTML をプレーンテキストに変換(基本的な方法)
const plainText = result.data.html
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return {
title: result.data.title,
text: plainText,
url: result.data.url,
publishedTime: result.data.publishedTime
};
}
// 使用例
const article = await extractArticleText('https://news.example.com/story');
console.log(article.title);
console.log(article.text.substring(0, 200) + '...');
複数ページを読み取り
import ZAI from 'z-ai-web-dev-sdk';
async function readMultiplePages(urls) {
const zai = await ZAI.create();
const results = [];
for (const url of urls) {
try {
const result = await zai.functions.invoke('page_reader', {
url: url
});
results.push({
url: url,
success: true,
data: result.data
});
} catch (error) {
results.push({
url: url,
success: false,
error: error.message
});
}
}
return results;
}
// 使用例
const urls = [
'https://example.com/article1',
'https://example.com/article2',
'https://example.com/article3'
];
const pages = await readMultiplePages(urls);
pages.forEach(page => {
if (page.success) {
console.log(`✓ ${page.data.title}`);
} else {
console.log(`✗ ${page.url}: ${page.error}`);
}
});
高度な使用例
Web コンテンツアナライザー
import ZAI from 'z-ai-web-dev-sdk';
class WebContentAnalyzer {
constructor() {
this.cache = new Map();
}
async initialize() {
this.zai = await ZAI.create();
}
async readPage(url, useCache = true) {
// キャッシュを確認
if (useCache && this.cache.has(url)) {
console.log('キャッシュされた結果を返す:', url);
return this.cache.get(url);
}
// 新しいコンテンツを取得
const result = await this.zai.functions.invoke('page_reader', {
url: url
});
// 結果をキャッシュ
if (useCache) {
this.cache.set(url, result.data);
}
return result.data;
}
async getPageMetadata(url) {
const data = await this.readPage(url);
return {
title: data.title,
url: data.url,
publishedTime: data.publishedTime,
contentLength: data.html.length,
wordCount: this.estimateWordCount(data.html)
};
}
estimateWordCount(html) {
const text = html.replace(/<[^>]*>/g, ' ');
const words = text.split(/\s+/).filter(word => word.length > 0);
return words.length;
}
async comparePages(url1, url2) {
const [page1, page2] = await Promise.all([
this.readPage(url1),
this.readPage(url2)
]);
return {
page1: {
title: page1.title,
wordCount: this.estimateWordCount(page1.html),
published: page1.publishedTime
},
page2: {
title: page2.title,
wordCount: this.estimateWordCount(page2.html),
published: page2.publishedTime
}
};
}
clearCache() {
this.cache.clear();
}
}
// 使用例
const analyzer = new WebContentAnalyzer();
await analyzer.initialize();
const metadata = await analyzer.getPageMetadata('https://example.com/article');
console.log('記事メタデータ:', metadata);
const comparison = await analyzer.comparePages(
'https://example.com/article1',
'https://example.com/article2'
);
console.log('比較:', comparison);
RSS フィードリーダー
import ZAI from 'z-ai-web-dev-sdk';
class FeedReader {
constructor() {
this.articles = [];
}
async initialize() {
this.zai = await ZAI.create();
}
async fetchArticlesFromUrls(urls) {
const articles = [];
for (const url of urls) {
try {
const result = await this.zai.functions.invoke('page_reader', {
url: url
});
articles.push({
title: result.data.title,
url: result.data.url,
publishedTime: result.data.publishedTime,
content: result.data.html,
fetchedAt: new Date().toISOString()
});
console.log(`取得: ${result.data.title}`);
} catch (error) {
console.error(`${url} の取得に失敗:`, error.message);
}
}
this.articles = articles;
return articles;
}
getRecentArticles(limit = 10) {
return this.articles
.sort((a, b) => {
const dateA = new Date(a.publishedTime || a.fetchedAt);
const dateB = new Date(b.publishedTime || b.fetchedAt);
return dateB - dateA;
})
.slice(0, limit);
}
searchArticles(keyword) {
return this.articles.filter(article => {
const searchText = `${article.title} ${article.content}`.toLowerCase();
return searchText.includes(keyword.toLowerCase());
});
}
}
// 使用例
const reader = new FeedReader();
await reader.initialize();
const feedUrls = [
'https://example.com/article1',
'https://example.com/article2',
'https://example.com/article3'
];
await reader.fetchArticlesFromUrls(feedUrls);
const recent = reader.getRecentArticles(5);
console.log('最新記事:', recent.map(a => a.title));
コンテンツアグリゲーター
import ZAI from 'z-ai-web-dev-sdk';
async function aggregateContent(urls, options = {}) {
const zai = await ZAI.create();
const aggregated = {
sources: [],
totalWords: 0,
aggregatedAt: new Date().toISOString()
};
for (const url of urls) {
try {
const result = await zai.functions.invoke('page_reader', {
url: url
});
const text = result.data.html.replace(/<[^>]*>/g, ' ');
const wordCount = text.split(/\s+/).filter(w => w.length > 0).length;
aggregated.sources.push({
title: result.data.title,
url: result.data.url,
publishedTime: result.data.publishedTime,
wordCount: wordCount,
excerpt: text.substring(0, 200).trim() + '...'
});
aggregated.totalWords += wordCount;
if (options.delay) {
await new Promise(resolve => setTimeout(resolve, options.delay));
}
} catch (error) {
console.error(`${url} の取得に失敗:`, error.message);
}
}
return aggregated;
}
// 使用例
const sources = [
'https://example.com/news1',
'https://example.com/news2',
'https://example.com/news3'
];
const aggregated = await aggregateContent(sources, { delay: 1000 });
console.log(`${aggregated.sources.length} 件のソースを集約`);
console.log(`総単語数: ${aggregated.totalWords}`);
Web スクレイピングパイプライン
import ZAI from 'z-ai-web-dev-sdk';
class ScrapingPipeline {
constructor() {
this.processors = [];
}
async initialize() {
this.zai = await ZAI.create();
}
addProcessor(name, processorFn) {
this.processors.push({ name, fn: processorFn });
}
async scrape(url) {
// ページを取得
const result = await this.zai.functions.invoke('page_reader', {
url: url
});
let data = {
raw: result.data,
processed: {}
};
// プロセッサーを実行
for (const processor of this.processors) {
try {
data.processed[processor.name] = await processor.fn(data.raw);
console.log(`✓ ${processor.name} で処理`);
} catch (error) {
console.error(`✗ ${processor.name} 失敗:`, error.message);
data.processed[processor.name] = null;
}
}
return data;
}
}
// プロセッサ関数
function extractLinks(pageData) {
const linkRegex = /href=["'](https?:\/\/[^"']+)["']/g;
const links = [];
let match;
while ((match = linkRegex.exec(pageData.html)) !== null) {
links.push(match[1]);
}
return [...new Set(links)]; // 重複を削除
}
function extractImages(pageData) {
const imgRegex = /src=["'](https?:\/\/[^"']+\.(jpg|jpeg|png|gif|webp))["']/gi;
const images = [];
let match;
while ((match = imgRegex.exec(pageData.html)) !== null) {
images.push(match[1]);
}
return [...new Set(images)];
}
function extractPlainText(pageData) {
return pageData.html
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// 使用例
const pipeline = new ScrapingPipeline();
await pipeline.initialize();
pipeline.addProcessor('links', extractLinks);
pipeline.addProcessor('images', extractImages);
pipeline.addProcessor('plainText', extractPlainText);
const result = await pipeline.scrape('https://example.com/article');
console.log('見つかったリンク:', result.processed.links.length);
console.log('見つかった画像:', result.processed.images.length);
console.log('テキスト長:', result.processed.plainText.length);
レスポンス形式
成功レスポンス
{
code: 200,
status: 200,
data: {
title: "記事タイトル",
url: "https://example.com/article",
html: "<div>記事コンテンツ...</div>",
publishedTime: "2025-01-15T10:30:00Z",
usage: {
tokens: 1500
}
},
meta: {
usage: {
tokens: 1500
}
}
}
レスポンスフィールド
| フィールド | 型 | 説明 |
|---|---|---|
code | number | レスポンスステータスコード |
status | number | HTTP ステータスコード |
data.title | string | ページタイトル |
data.url | string | ページ URL |
data.html | string | 抽出された HTML コンテンツ |
data.publishedTime | string | 公開日(オプション) |
data.usage.tokens | number | 処理に使用されたトークン |
meta.usage.tokens | number | 使用された総トークン数 |
ベストプラクティス
1. エラーハンドリング
async function safeReadPage(url) {
try {
const zai = await ZAI.create();
// URL を検証
if (!url || !url.startsWith('http')) {
throw new Error('無効な URL 形式');
}
const result = await zai.functions.invoke('page_reader', {
url: url
});
// レスポンスステータスを確認
if (result.code !== 200) {
throw new Error(`ページ取得失敗: ${result.code}`);
}
// 必須データを検証
if (!result.data.html || !result.data.title) {
throw new Error('不完全なページデータを受信');
}
return {
success: true,
data: result.data
};
} catch (error) {
console.error('ページ読み取りエラー:', error);
return {
success: false,
error: error.message
};
}
}
2. レート制限
class RateLimitedReader {
constructor(requestsPerMinute = 10) {
this.requestsPerMinute = requestsPerMinute;
this.requestTimes = [];
}
async initialize() {
this.zai = await ZAI.create();
}
async readPage(url) {
await this.waitForRateLimit();
const result = await this.zai.functions.invoke('page_reader', {
url: url
});
this.requestTimes.push(Date.now());
return result.data;
}
async waitForRateLimit() {
const now = Date.now();
const oneMinuteAgo = now - 60000;
// 古いタイムスタンプを削除
this.requestTimes = this.requestTimes.filter(time => time > oneMinuteAgo);
// 待つ必要があるかチェック
if (this.requestTimes.length >= this.requestsPerMinute) {
const oldestRequest = this.requestTimes[0];
const waitTime = 60000 - (now - oldestRequest);
if (waitTime > 0) {
console.log(`レート制限に達しました。${waitTime}ms 待機中...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}
}
}
// 使用例
const reader = new RateLimitedReader(10); // 1 分当たり 10 リクエスト
await reader.initialize();
const urls = ['https://example.com/1', 'https://example.com/2'];
for (const url of urls) {
const data = await reader.readPage(url);
console.log('取得:', data.title);
}
3. キャッシュ戦略
import ZAI from 'z-ai-web-dev-sdk';
class CachedWebReader {
constructor(cacheDuration = 3600000) { // デフォルト 1 時間
this.cache = new Map();
this.cacheDuration = cacheDuration;
}
async initialize() {
this.zai = await ZAI.create();
}
async readPage(url, forceRefresh = false) {
const cacheKey = url;
const cached = this.cache.get(cacheKey);
// キャッシュが有効で強制更新でない場合は返す
if (cached && !forceRefresh) {
const age = Date.now() - cached.timestamp;
if (age < this.cacheDuration) {
console.log('キャッシュコンテンツを返す:', url);
return cached.data;
}
}
// 新しいコンテンツを取得
const result = await this.zai.functions.invoke('page_reader', {
url: url
});
// キャッシュを更新
this.cache.set(cacheKey, {
data: result.data,
timestamp: Date.now()
});
return result.data;
}
clearCache() {
this.cache.clear();
}
getCacheStats() {
return {
size: this.cache.size,
entries: Array.from(this.cache.keys())
};
}
}
// 使用例
const reader = new CachedWebReader(3600000); // 1 時間キャッシュ
await reader.initialize();
const data1 = await reader.readPage('https://example.com'); // 新規取得
const data2 = await reader.readPage('https://example.com'); // キャッシュから
const data3 = await reader.readPage('https://example.com', true); // 強制更新
4. 並列処理
import ZAI from 'z-ai-web-dev-sdk';
async function readPagesInParallel(urls, concurrency = 3) {
const zai = await ZAI.create();
const results = [];
// バッチで処理
for (let i = 0; i < urls.length; i += concurrency) {
const batch = urls.slice(i, i + concurrency);
const batchResults = await Promise.allSettled(
batch.map(url =>
zai.functions.invoke('page_reader', { url })
.then(result => ({
url: url,
success: true,
data: result.data
}))
.catch(error => ({
url: url,
success: false,
error: error.message
}))
)
);
results.push(...batchResults.map(r => r.value));
console.log(`バッチ ${Math.floor(i / concurrency) + 1} 完了`);
}
return results;
}
// 使用例
const urls = [
'https://example.com/1',
'https://example.com/2',
'https://example.com/3',
'https://example.com/4',
'https://example.com/5'
];
const results = await readPagesInParallel(urls, 2); // 2 並行リクエスト
results.forEach(result => {
if (result.success) {
console.log(`✓ ${result.data.title}`);
} else {
console.log(`✗ ${result.url}: ${result.error}`);
}
});
5. コンテンツ処理
import ZAI from 'z-ai-web-dev-sdk';
class ContentProcessor {
static extractMainContent(html) {
// スクリプト、スタイル、コメントを削除
let content = html
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '');
return content;
}
static htmlToPlainText(html) {
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>/gi, '\n\n')
.replace(/<[^>]*>/g, '')
.replace(/ /g, ' ')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/\s+/g, ' ')
.trim();
}
static extractMetadata(html) {
const metadata = {};
// メタ説明を抽出
const descMatch = html.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)["']/i);
if (descMatch) metadata.description = descMatch[1];
// キーワードを抽出
const keywordsMatch = html.match(/<meta\s+name=["']keywords["']\s+content=["']([^"']+)["']/i);
if (keywordsMatch) metadata.keywords = keywordsMatch[1].split(',').map(k => k.trim());
// 著者を抽出
const authorMatch = html.match(/<meta\s+name=["']author["']\s+content=["']([^"']+)["']/i);
if (authorMatch) metadata.author = authorMatch[1];
return metadata;
}
}
// 使用例
async function processWebPage(url) {
const zai = await ZAI.create();
const result = await zai.functions.invoke('page_reader', { url });
return {
title: result.data.title,
url: result.data.url,
mainContent: ContentProcessor.extractMainContent(result.data.html),
plainText: ContentProcessor.htmlToPlainText(result.data.html),
metadata: ContentProcessor.extractMetadata(result.data.html),
publishedTime: result.data.publishedTime
};
}
const processed = await processWebPage('https://example.com/article');
console.log('処理済みコンテンツ:', processed.title);
一般的な使用例
- ニュース集約: 複数のソースからニュース記事を収集して集約
- コンテンツ監視: 特定の Web ページの変更を追跡
- 研究ツール: アカデミックまたはリファレンス Web サイトから情報を抽出
- 価格追跡: 商品ページの価格変更を監視
- SEO 分析: SEO 目的でページメタデータとコンテンツを抽出
- アーカイブ作成: Web コンテンツのローカルコピーを作成
- コンテンツキュレーション: トピック別に Web コンテンツを収集して整理
- 競争力調査: 競合企業の Web サイトを監視して更新を検出
統合例
Express.js API エンドポイント
import express from 'express';
import ZAI from 'z-ai-web-dev-sdk';
const app = express();
app.use(express.json());
let zaiInstance;
async function initZAI() {
zaiInstance = await ZAI.create();
}
app.post('/api/read-page', async (req, res) => {
try {
const { url } = req.body;
if (!url) {
return res.status(400).json({
error: 'URL が必須です'
});
}
const result = await zaiInstance.functions.invoke('page_reader', {
url: url
});
res.json({
success: true,
data: {
title: result.data.title,
url: result.data.url,
content: result.data.html,
publishedTime: result.data.publishedTime,
tokensUsed: result.data.usage.tokens
}
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
app.post('/api/read-multiple', async (req, res) => {
try {
const { urls } = req.body;
if (!urls || !Array.isArray(urls)) {
return res.status(400).json({
error: 'URL 配列が必須です'
});
}
const results = await Promise.allSettled(
urls.map(url =>
zaiInstance.functions.invoke('page_reader', { url })
.then(result => ({
url: url,
success: true,
data: result.data
}))
.catch(error => ({
url: url,
success: false,
error: error.message
}))
)
);
res.json({
success: true,
results: results.map(r => r.value)
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
initZAI().then(() => {
app.listen(3000, () => {
console.log('Web reader API がポート 3000 で実行中');
});
});
スケジュール済みコンテンツフェッチャー
import ZAI from 'z-ai-web-dev-sdk';
import cron from 'node-cron';
class ScheduledFetcher {
constructor() {
this.urls = [];
this.results = [];
}
async initialize() {
this.zai = await ZAI.create();
}
addUrl(url, schedule) {
this.urls.push({ url, schedule });
}
async fetchContent(url) {
try {
const result = await this.zai.functions.invoke('page_reader', {
url: url
});
return {
url: url,
success: true,
title: result.data.title,
content: result.data.html,
fetchedAt: new Date().toISOString()
};
} catch (error) {
return {
url: url,
success: false,
error: error.message,
fetchedAt: new Date().toISOString()
};
}
}
startScheduledFetch(url, schedule) {
cron.schedule(schedule, async () => {
console.log(`${url} を取得中...`);
const result = await this.fetchContent(url);
this.results.push(result);
// 最新 100 件の結果のみを保持
if (this.results.length > 100) {
this.results = this.results.slice(-100);
}
console.log(`取得完了: ${result.success ? result.title : result.error}`);
});
}
start() {
for (const { url, schedule } of this.urls) {
this.startScheduledFetch(url, schedule);
}
}
getResults() {
return this.results;
}
}
// 使用例
const fetcher = new ScheduledFetcher();
await fetcher.initialize();
// 1 時間ごとに取得
fetcher.addUrl('https://example.com/news', '0 * * * *');
// 毎日午前 0 時に取得
fetcher.addUrl('https://example.com/daily', '0 0 * * *');
fetcher.start();
console.log('スケジュール済み取得を開始');
サイトクローリングとスパイダリング
Web Reader の page_reader 関数は個別のページを読み取りますが、現実の多くのタスクではサイト全体をクロールする必要があります — ドキュメンテーションサイトからすべてのページを抽出したり、ブログをアーカイブしたり、分析用のコンテンツコーパスを構築したりしています。このセクションでは、既存の page_reader 関数を基にしたキュー ベースの複数ページクローラーパターンを提供します。
アーキテクチャ: キューベースクローラー
クローラーは幅優先アプローチを使用し、URL キュー、重複検出用の訪問済みセット、スコープを制御する設定可能なフィルターを備えています:
┌─────────────┐
│ シード URL │
└──────┬──────┘
▼
┌─────────────┐
┌────►│ URL キュー │◄────┐
│ └──────┬──────┘ │
│ ▼ │
│ ┌─────────────┐ │
│ │ robots.txt │ │
│ │ チェック │ │
│ └──────┬──────┘ │
│ ▼ │
│ ┌─────────────┐ │
│ │ URL フィルター │ │
│ │ (含む/除外/ │ │
│ │ 深さ) │ │
│ └──────┬──────┘ │
│ ▼ │
│ ┌─────────────┐ │
│ │ page_reader │ │
│ └──────┬──────┘ │
│ ▼ │
│ ┌─────────────┐ │
│ │ 新しいリンクを │─────┐
│ │ 抽出 │ │
│ └──────┬──────┘ │
│ ▼ │
│ ┌─────────────┐ │
└─────│ 訪問済み │
│ セット │
└─────────────┘
動作中のサイトクローラー実装
import ZAI from 'z-ai-web-dev-sdk';
class SiteCrawler {
/**
* @param {Object} options
* @param {string} options.seedUrl - クロール開始 URL
* @param {number} [options.maxDepth=3] - シードからの最大リンクホップ数
* @param {number} [options.maxPages=100] - 取得する最大ページ数
* @param {number} [options.concurrency=3] - 並行フェッチ数
* @param {number} [options.delayMs=500] - バッチ間の遅延(ミリ秒)
* @param {string[]} [options.pathIncludes] - これらのプレフィックスに一致するパスのみクロール(例: ['/docs/', '/blog/'])
* @param {string[]} [options.pathExcludes] - これらのプレフィックスに一致するパスをスキップ(例: ['/api/', '/login'])
* @param {string[]} [options.sameDomain=true] - クロールをシード URL のドメインに制限
* @param {boolean} [options.respectRobotsTxt=true] - フェッチ前に robots.txt をチェック
* @param {Function} [options.onPage] - 各ページが正常に取得されたときに実行されるコールバック
* @param {Function} [options.onProgress] - クロール進捗で実行されるコールバック
*/
constructor(options = {}) {
this.seedUrl = options.seedUrl;
this.maxDepth = options.maxDepth ?? 3;
this.maxPages = options.maxPages ?? 100;
this.concurrency = options.concurrency ?? 3;
this.delayMs = options.delayMs ?? 500;
this.pathIncludes = options.pathIncludes ?? [];
this.pathExcludes = options.pathExcludes ?? [];
this.sameDomain = options.sameDomain ?? true;
this.respectRobotsTxt = options.respectRobotsTxt ?? true;
this.onPage = options.onPage ?? null;
this.onProgress = options.onProgress ?? null;
// 内部状態
this.queue = []; // [{url, depth}]
this.visited = new Set(); // 既に表示されている正規化された URL
this.results = []; // 取得したページデータ
this.errors = []; // 失敗したフェッチ
this.disallowedPaths = new Set(); // robots.txt から
this.seedDomain = null;
this.zai = null;
}
// ── 初期化 ──────────────────────────────────────────
async initialize() {
this.zai = await ZAI.create();
this.seedDomain = this.extractDomain(this.seedUrl);
this.queue.push({ url: this.normalizeUrl(this.seedUrl), depth: 0 });
if (this.respectRobotsTxt) {
await this.loadRobotsTxt();
}
}
// ── URL 正規化 ──────────────────────────────────────
normalizeUrl(rawUrl) {
try {
const url = new URL(rawUrl);
// フラグメント(アンカー)を削除 — 同じページコンテンツ
url.hash = '';
// 末尾のスラッシュを一貫性のために削除
let normalized = url.toString();
if (normalized.endsWith('/') && normalized.length > 1) {
normalized = normalized.slice(0, -1);
}
return normalized;
} catch {
return rawUrl;
}
}
extractDomain(url) {
try {
return new URL(url).hostname;
} catch {
return null;
}
}
// ── robots.txt ハンドリング ────────────────────────────────────
async loadRobotsTxt() {
try {
const robotsUrl = `${new URL(this.seedUrl).origin}/robots.txt`;
const result = await this.zai.functions.invoke('page_reader', {
url: robotsUrl
});
const text = result.data.html
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
// 基本的な Disallow ルールを解析
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (trimmed.startsWith('Disallow:')) {
const path = trimmed.replace('Disallow:', '').trim();
if (path && path !== '/') {
this.disallowedPaths.add(path);
}
}
}
console.log(`robots.txt 読み込み: ${this.disallowedPaths.size} 個の Disallow ルール`);
} catch {
console.log('robots.txt が見つからないか、フェッチ失敗 — 注意して続行');
}
}
isAllowedByRobots(url) {
try {
const pathname = new URL(url).pathname;
for (const disallowed of this.disallowedPaths) {
if (pathname.startsWith(disallowed)) return false;
}
return true;
} catch {
return false;
}
}
// ── URL フィルタリング ──────────────────────────────────────
shouldCrawl(url, depth) {
// 深さチェック
if (depth > this.maxDepth) return false;
// ページ数制限
if (this.visited.size >= this.maxPages) return false;
// ドメイン制限
if (this.sameDomain && this.extractDomain(url) !== this.seedDomain) return false;
// パス含む(ホワイトリスト)
if (this.pathIncludes.length > 0) {
try {
const pathname = new URL(url).pathname;
const matchesInclude = this.pathIncludes.some(prefix =>
pathname.startsWith(prefix)
);
if (!matchesInclude) return false;
} catch {
return false;
}
}
// パス除外(ブラックリスト)
if (this.pathExcludes.length > 0) {
try {
const pathname = new URL(url).pathname;
const matchesExclude = this.pathExcludes.some(prefix =>
pathname.startsWith(prefix)
);
if (matchesExclude) return false;
} catch {
return false;
}
}
// robots.txt
if (this.respectRobotsTxt && !this.isAllowedByRobots(url)) return false;
return true;
}
// ── リンク抽出 ────────────────────────────────────────
extractLinks(html, sourceUrl) {
const links = [];
const linkRegex = /href=["'](https?:\/\/[^"']+)["']/g;
let match;
try {
const sourceOrigin = new URL(sourceUrl).origin;
while ((match = linkRegex.exec(html)) !== null) {
const rawLink = match[1];
// 相対 URL を絶対 URL に変換
let absoluteUrl;
try {
absoluteUrl = new URL(rawLink, sourceUrl).toString();
} catch {
continue;
}
// HTTP/HTTPS のみを含める
if (!absoluteUrl.startsWith('http')) continue;
const normalized = this.normalizeUrl(absoluteUrl);
links.push(normalized);
}
} catch {
// ソース URL が無効 — リンク抽出をスキップ
}
return [...new Set(links)]; // 重複排除
}
// ── 進捗追跡 ──────────────────────────────────────
reportProgress() {
if (this.onProgress) {
this.onProgress({
queued: this.queue.length,
visited: this.visited.size,
fetched: this.results.length,
errors: this.errors.length,
maxPages: this.maxPages,
percentComplete: Math.min(100, Math.round((this.visited.size / this.maxPages) * 100))
});
}
}
// ── コアクロールループ ────────────────────────────────────────
async crawl() {
if (!this.zai) await this.initialize();
console.log(`クロール開始: ${this.seedUrl}`);
console.log(` 最大深さ: ${this.maxDepth}, 最大ページ数: ${this.maxPages}`);
console.log(` 同時実行数: ${this.concurrency}, 遅延: ${this.delayMs}ms`);
while (this.queue.length > 0 && this.visited.size < this.maxPages) {
// バッチをデキュー
const batchSize = Math.min(this.concurrency, this.queue.length);
const batch = this.queue.splice(0, batchSize);
// フェッチ前にフィルタリングして重複排除
const toFetch = batch.filter(item => {
if (this.visited.has(item.url)) return false;
if (!this.shouldCrawl(item.url, item.depth)) return false;
this.visited.add(item.url); // 再キュー防止のため即座にマーク
return true;
});
// バッチを並行フェッチ
const batchResults = await Promise.allSettled(
toFetch.map(async (item) => {
const result = await this.zai.functions.invoke('page_reader', {
url: item.url
});
return {
url: item.url,
depth: item.depth,
data: result.data
};
})
);
// 結果を処理
for (let i = 0; i < batchResults.length; i++) {
const settled = batchResults[i];
const item = toFetch[i];
if (settled.status === 'fulfilled') {
const { url, depth, data } = settled.value;
this.results.push({
url: url,
title: data.title,
html: data.html,
depth: depth,
fetchedAt: new Date().toISOString()
});
console.log(` [${this.results.length}] ✓ ${data.title} (深さ ${depth})`);
// ページごとのコールバック実行
if (this.onPage) {
this.onPage({ url, title: data.title, html: data.html, depth });
}
// 新しいリンクを抽出してエンキュー(最大深さに達していない場合)
if (depth < this.maxDepth) {
const newLinks = this.extractLinks(data.html, url);
for (const link of newLinks) {
if (!this.visited.has(link) && !this.queue.some(q => q.url === link)) {
this.queue.push({ url: link, depth: depth + 1 });
}
}
}
} else {
const error = settled.reason?.message || '不明なエラー';
this.errors.push({ url: item.url, error });
console.log(` ✗ ${item.url}: ${error}`);
}
}
// 進捗を報告
this.reportProgress();
// レート制限: バッチ間で待機
if (this.queue.length > 0 && this.delayMs > 0) {
await new Promise(resolve => setTimeout(resolve, this.delayMs));
}
}
console.log(`\nクロール完了: ${this.results.length} ページ取得, ${this.errors.length} エラー`);
return this.buildReport();
}
// ── レポート生成 ──────────────────────────────────────
buildReport() {
return {
seedUrl: this.seedUrl,
domain: this.seedDomain,
totalPages: this.results.length,
maxDepthReached: Math.max(0, ...this.results.map(r => r.depth)),
errors: this.errors.length,
startedAt: this.results[0]?.fetchedAt ?? null,
completedAt: this.results[this.results.length - 1]?.fetchedAt ?? null,
pages: this.results.map(r => ({
url: r.url,
title: r.title,
depth: r.depth,
wordCount: r.html.replace(/<[^>]*>/g, ' ').split(/\s+/).filter(w => w.length > 0).length,
fetchedAt: r.fetchedAt
})),
failedUrls: this.errors.map(e => ({
url: e.url,
error: e.error
}))
};
}
}
// ── 使用例 ───────────────────────────────────────────
// 例 1: ドキュメンテーションサイトをクロール(スコープ制限)
const docsCrawler = new SiteCrawler({
seedUrl: 'https://docs.example.com/getting-started',
maxDepth: 2,
maxPages: 50,
pathIncludes: ['/docs/'],
pathExcludes: ['/api/', '/internal/'],
concurrency: 3,
delayMs: 1000,
onPage: (page) => {
// 各ページをディスクに保存または段階的に処理
console.log(` 保存: ${page.title}`);
},
onProgress: (stats) => {
console.log(`進捗: ${stats.fetched}/${stats.maxPages} ページ (${stats.percentComplete}%)`);
}
});
await docsCrawler.initialize();
const docsReport = await docsCrawler.crawl();
console.log(`${docsReport.totalPages} 個のドキュメンテーションページを取得`);
// 例 2: ブログを幅広くクロール(深さ制限付き)
const blogCrawler = new SiteCrawler({
seedUrl: 'https://techblog.com',
maxDepth: 3,
maxPages: 200,
pathIncludes: ['/blog/', '/articles/'],
pathExcludes: ['/tag/', '/author/', '/page/'], // ページネーション/タグページをスキップ
concurrency: 5,
delayMs: 500,
respectRobotsTxt: true
});
await blogCrawler.initialize();
const blogReport = await blogCrawler.crawl();
// 例 3: クロール結果を JSON としてエクスポート
import fs from 'fs';
fs.writeFileSync(
'./crawl-results.json',
JSON.stringify(blogReport, null, 2)
);
console.log(`レポート保存: ${blogReport.totalPages} ページ, ${blogReport.failedUrls.length} 件の失敗`);
クロール設定ガイド
| パラメータ | デフォルト | 推奨用途 | 説明 |
|---|---|---|---|
maxDepth | 3 | ドキュメント: 2, ブログ: 3, サイト全体: 5 | シード URL からの最大リンクホップ数 |
maxPages | 100 | 小サイト: 50, 中規模: 200, 大規模: 500+ | 取得されるページ総数のハード上限 |
concurrency | 3 | 丁寧: 2, 通常: 5, 積極的: 10 | バッチごとの並行ページ取得数 |
delayMs | 500 | 丁寧: 1000, 通常: 500, 高速: 200 | バッチ間のミリ秒遅延 |
pathIncludes | [] | /docs/, /blog/, /products/ にスコープ | ホワイトリスト — 一致するパスのみクロール |
pathExcludes | [] | /api/, /login, /tag/, /page/ をスキップ | ブラックリスト — 一致するパスは決してクロールしない |
sameDomain | true | サイトクロールでは常に true | 外部ドメインへのリンクフォローを防止 |
respectRobotsTxt | true | 倫理的クロールでは常に true | robots.txt の Disallow ルールを遵守 |
サイトクローリングのベストプラクティス
- 狭くスタート、段階的に拡大 — 制限的な
pathIncludesと低いmaxPagesでスタートし、サイト構造を理解したら拡大 - レート制限を尊重 — 管理していないサイトには
delayMs: 1000(1 秒)以上を使用。一部のサイトは積極的なクローラーをブロックする可能性があります - robots.txt を最初にチェック — 明示的な許可がない限り、常に
respectRobotsTxt: trueを設定 - パスフィルターを積極的に使用 — ページネーション(
/page/2など)、タグアーカイブ、管理パスを除外して、低価値ページでリクエストを無駄にしない onProgressで監視 — リアルタイムで進捗を追跡して、無限クロールループや予期しないサイト構造を検出- 結果を段階的に保存 —
onPageコールバックを使用して各ページをディスクに書き込み、クロール中断時にデータを失わない - URL を一貫性を持って正規化 — 組み込み正規化はフラグメントと末尾スラッシュをストリップします。サイトがナビゲーション用クエリパラメータを使用する場合は拡張します
- クロール中断を処理 — 最後のバッチの訪問済み URL をリシード して クローラーを再開可能
サイトクローリングのための CLI クイックスタート
シンプルなクロールタスクの場合、シェルループで CLI を使用:
#!/bin/bash
# quick_crawl.sh — CLI を使用した最小限のサイトクローラー
SEED_URL="https://docs.example.com/getting-started"
DOMAIN="docs.example.com"
MAX_PAGES=30
OUTPUT_DIR="./crawl_output"
VISITED_FILE="$OUTPUT_DIR/visited.txt"
mkdir -p "$OUTPUT_DIR"
touch "$VISITED_FILE"
# キューはシード URL でスタート
QUEUE=("$SEED_URL")
while [ ${#QUEUE[@]} -gt 0 ] && [ "$(wc -l < "$VISITED_FILE")" -lt "$MAX_PAGES" ]; do
URL="${QUEUE[0]}"
QUEUE=("${QUEUE[@]:1}")
# 既に訪問済みの場合はスキップ
if rg -q "^${URL}$" "$VISITED_FILE" 2>/dev/null; then
continue
fi
# 同一ドメインチェック
if [[ "$URL" != *"$DOMAIN"* ]]; then
continue
fi
echo "フェッチ中: $URL"
FILENAME=$(echo "$URL" | md5sum | cut -d' ' -f1)
if z-ai function -n page_reader -a "{\"url\": \"$URL\"}" -o "$OUTPUT_DIR/${FILENAME}.json" 2>/dev/null; then
echo "$URL" >> "$VISITED_FILE"
# リンクを抽出してキューに追加(jq が必要)
if command -v jq &>/dev/null; then
NEW_LINKS=$(jq -r '[.html // "" | match("href=\"(https?://[^\"]+)\"; \"g") | .string // empty | split("\"")[1]] | .[]' "$OUTPUT_DIR/${FILENAME}.json" 2>/dev/null | sort -u)
for link in $NEW_LINKS; do
# フラグメントをストリップ
CLEAN_LINK="${link%%#*}"
if ! rg -q "^${CLEAN_LINK}$" "$VISITED_FILE" 2>/dev/null; then
QUEUE+=("$CLEAN_LINK")
fi
done
fi
fi
sleep 1 # 丁寧な遅延
done
TOTAL=$(wc -l < "$VISITED_FILE")
echo "クロール完了: $OUTPUT_DIR で $TOTAL ページ取得"
トラブルシューティング
問題:
ライセンス: MIT(寛容ライセンスのため全文を引用しています) · 原本リポジトリ
詳細情報
- 作者
- marktantongco
- ライセンス
- MIT
- 最終更新
- 2026/4/16
Source: https://github.com/marktantongco/ai-skills-library / ライセンス: MIT
関連スキル
doubt-driven-development
重要な判断はすべて、本番環境への展開前に新しい視点から対抗的レビューを実施します。速度より正確性が重要な場合、不慣れなコードを扱う場合、本番環境・セキュリティに関わるロジック・取り消し不可の操作など影響度が高い場合、または後でバグを修正するよりも今検証する方が効率的な場合に活用してください。
apprun-skills
TypeScriptを使用したAppRunアプリケーションのMVU設計に関する総合的なガイダンスが得られます。コンポーネントパターン、イベントハンドリング、状態管理(非同期ジェネレータを含む)、パラメータと保護機能を備えたルーティング・ナビゲーション、vistestを使用したテストに対応しています。AppRunコンポーネントの設計・レビュー、ルートの配線、状態フローの管理、AppRunテストの作成時に活用してください。
desloppify
コードベースのヘルスチェックと技術負債の追跡ツールです。コード品質、技術負債、デッドコード、大規模ファイル、ゴッドクラス、重複関数、コードスメル、命名規則の問題、インポートサイクル、結合度の問題についてユーザーが質問した場合に使用してください。また、ヘルススコアの確認、次の改善項目の提案、クリーンアップ計画の作成をリクエストされた際にも対応します。29言語に対応しています。
debugging-and-error-recovery
テストが失敗したり、ビルドが壊れたり、動作が期待と異なったり、予期しないエラーが発生したりした場合に、体系的な根本原因デバッグをガイドします。推測ではなく、根本原因を見つけて修正するための体系的なアプローチが必要な場合に使用してください。
test-driven-development
テスト駆動開発により実装を進めます。ロジックの実装、バグの修正、動作の変更など、あらゆる場面で活用できます。コードが正常に動作することを証明する必要がある場合、バグ報告を受けた場合、既存機能を修正する予定がある場合に使用してください。
incremental-implementation
変更を段階的に実施します。複数のファイルに影響する機能や変更を実装する場合に使用してください。大量のコードを一度に書こうとしている場合や、タスクが一度では完結できないほど大きい場合に活用します。