logo

使用 PHP 的 Puppeteer 进行网页抓取

2024-06-22 12:55
本文介绍了如何使用 PHP 的 Puppeteer 进行网页抓取。

集蜂云是一个可以让开发者在上面构建、部署、运行、发布采集器的数据采集云平台。加入到数百名开发者中,将你的采集器发布到市场,从而给你带来被动收入吧!

Puppeteer 是一款功能强大的工具,可通过浏览器自动化进行测试和网页抓取。虽然它是一个 JavaScript 库,但它的流行促使开发者社区为其他语言创建非官方端口。其中之一就是 Puppeteer PHP 库,它允许 PHP 用户无需切换到 JavaScript 即可获得 Puppeteer 的好处。

在本指南中,您将了解 PHP 版 Puppeteer 的基础知识,然后了解更复杂的浏览器交互。您将了解:

  • 如何在 PHP 中使用 Puppeteer。
  • 像人类一样在浏览器中与网页进行交互。
  • 避免受到阻塞。 来吧!

为什么要将 Puppeteer 与 PHP 结合使用?

Puppeteer是一款深受开发人员喜爱的 JavaScript 无头浏览器库,因为它拥有直观而丰富的 API。它简化了跨平台浏览器自动化,支持测试和网页抓取活动。

Puppeteer 有一个非官方的 PHP 端口,名为PuPHPeteer。截至 2023 年 1 月 1 日,原始项目不再维护。不过,有几个最新的分支,zoonru/puphpeteer是最受欢迎的一个。

如何在 PHP 中使用 Puppeteer 进行抓取?

让我们开始使用 Puppeteer for PHP 的第一步。您将构建一个自动脚本,从此无限滚动演示中提取数据:

无限滚动页面

当您向下滚动时,此网页会通过 AJAX 动态加载新产品。与其交互需要能够执行 JavaScript 的浏览器自动化工具,例如 Puppeteer。

按照以下步骤构建针对此动态内容页面的数据抓取工具!

步骤1:下载PuPHPeteer

开始之前,请确保您的机器上安装了PHP 8+、Composer和Node.js。按照三个链接获取设置说明。

现在,您已经拥有初始化 PHP Composer 项目所需的一切。创建一个php-puppeteer-project文件夹并将其输入到终端中:

mkdir php-puppeteer-project
cd php-puppeteer-project

运行init命令在文件夹内创建一个新的 Composer 项目。按照向导并根据需要回答问题。默认答案将执行以下操作:

composer init
php-puppeteer-project现在包含 Composer 项目。

使用以下命令将zoon/puphpeteer添加到项目依赖项中:

composer require zoon/puphpeteer
该命令可能由于以下错误而失败:
- clue/socket-raw[v1.2.0, ..., v1.6.0] require ext-sockets * -> it is missing from your system. Install or enable PHP's sockets extension.

在这种情况下,您需要安装并启用ext-sockets PHP extension。然后重新启动上述composer require命令。

接下来,安装github:zoonru/puphpeteernpm 包:

npm install github:zoonru/puphpeteer

这将需要一段时间,所以请耐心等待。

在您最喜欢的 PHP IDE(例如带有 PHP 扩展的 Visual Studio Code)中加载项目文件夹。在文件夹中创建一个scraper.php文件/src并导入 Puppeteer PHP 包:

<?php

require_once ('vendor/autoload.php');

use Nesk\Puphpeteer\Puppeteer;

// scraping logic...

您可以在项目的根文件夹中使用此命令运行上述 PHP Puppeteer 脚本:

php src/scraper.php

您的 PHP 设置已准备就绪!

步骤 2:使用 Puppeteer 获取源 HTML

首先,初始化一个 Puppeteer 实例并启动它以打开可控制的 Chromium 窗口:

$puppeteer = new Puppeteer();
$browser = $puppeteer->launch([
  'headless' => true, // set to false while developing locally
]);

笔记 默认情况下,PHP Puppeteer 以无头模式启动 Chromium。如果您想要在浏览器中跟踪脚本在目标页面上的操作,请将headless选项设置为。这对于调试特别有用!false

现在可以初始化一个新页面以及goto()访问目标页面的方法:

$page = $browser->newPage();
$page->goto('https://scrapingclub.com/exercise/list_infinite_scroll/');

然后,使用该content()方法检索页面的 HTML 源代码。使用以下命令在终端中打印它echo:

$html = $page->content();
echo $html;

不要忘记在脚本末尾使用此行来释放浏览器资源:

$browser->close();

此时应包含以下内容scraper.php:

scraper.php
<?php

require_once ('vendor/autoload.php');

use Nesk\Puphpeteer\Puppeteer;

// open a new Chromium browser window
$puppeteer = new Puppeteer();
$browser = $puppeteer->launch([
  'headless' => true, // set to false while developing locally
]);

// open a new page in the browser
$page = $browser->newPage();
// visit the target page
$page->goto('https://scrapingclub.com/exercise/list_infinite_scroll/');

// retrieve the source HTML code of the page and
// print it
$html = $page->content();
echo $html;

// release the browser resources
$browser->close();

在 headed 模式下运行脚本。Puppeteer PHP 库将打开一个 Chromium 窗口并访问 Infinite Scrolling 演示页面:

无限滚动演示 PHP 脚本还将在终端中打印以下 HTML:

<html class="h-full"><head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="Learn to scrape infinite scrolling pages"><title>Scraping Infinite Scrolling Pages (Ajax) | ScrapingClub</title>
  <link rel="icon" href="/static/img/icon.611132651e39.png" type="image/png">
  <!-- Omitted for brevity... -->

好了!这就是目标页面的 HTML 代码。在下一步中,您将了解如何从中提取数据。

步骤3:提取所需数据

Puppeteer 解析页面的 HTML 内容并提供从中提取数据的 API。

假设您的 PHP 抓取脚本的目标是收集页面上每个产品元素的名称和价格。您需要执行以下操作:

  1. 通过应用有效的 HTML 节点选择策略来选择产品。
  2. 从每个信息中提取所需的信息。
  3. 将抓取的数据存储在 PHP 数组中。

PuPHPeteer 支持XPath表达式和CSS 选择器,这是从 DOM 获取元素的两种最流行的节点选择策略。CSS 选择器易于使用且直观,而 XPath 表达式更灵活但更复杂。

如需完整比较,请阅读我们的CSS 选择器与 XPath 指南。

为了简单起见,我们来选择 CSS 选择器。要定义正确的选择器,您需要查看产品节点的 HTML 代码。因此,在浏览器中打开目标网站并使用 DevTools 检查它:

检查元素 点击全屏打开图片 展开 HTML 代码并注意每个产品:

是一个具有类的元素post。 包含 中的名称和 中的价格。 由于该页面包含许多产品,因此$products为抓取的数据初始化一个数组:

$products = [];

接下来,使用该querySelectorAll()方法并选择 HTML 产品节点。这将在页面上应用 CSS 选择器:

$product_elements = $page->querySelectorAll('.post');

迭代它们并应用数据提取逻辑。检索感兴趣的数据,创建新对象,并使用它们来填充$products:

foreach ($product_elements as $product_element) {
  // select the name and price elements
  $name_element = $product_element->querySelector('h4');
  $price_element = $product_element->querySelector('h5');

  // retrieve the data of interest
  $name = $name_element->evaluate(JsFunction::createWithParameters(['node'])->body('return node.innerText;'));
  $price = $price_element->evaluate(JsFunction::createWithParameters(['node'])->body('return node.innerText;'));

  // create a new product object and add it to the list
  $product = ['name' => $name, 'price' => $price];
  $products[] = $product;
}

要使用 Puppeteer PHP 包从节点提取数据,您必须编写自定义的JsFunction。将其传递给evaluate()方法以将其应用于给定的节点。

使用以下方式记录所有抓取的产品:

print_r($products);
您的 PHP Puppeteerscraper.php脚本现在将包含:

scraper.php
<?php

require_once ('vendor/autoload.php');

use Nesk\Puphpeteer\Puppeteer;
use Nesk\Rialto\Data\JsFunction;

// open a new Chromium browser window
$puppeteer = new Puppeteer();
$browser = $puppeteer->launch([
  'headless' => true, // set to false while developing locally
]);

// open a new page in the browser
$page = $browser->newPage();
// visit the target page
$page->goto('https://scrapingclub.com/exercise/list_infinite_scroll/');

// where to store the scraped data
$products = [];

// select all product nodes on the page
$product_elements = $page->querySelectorAll('.post');

// iterate over the product elements and
// apply the scraping logic
foreach ($product_elements as $product_element) {
  // select the name and price elements
  $name_element = $product_element->querySelector('h4');
  $price_element = $product_element->querySelector('h5');

  // retrieve the data of interest
  $name = $name_element->evaluate(JsFunction::createWithParameters(['node'])->body('return node.innerText;'));
  $price = $price_element->evaluate(JsFunction::createWithParameters(['node'])->body('return node.innerText;'));

  // create a new product object and add it to the list
  $product = ['name' => $name, 'price' => $price];
  $products[] = $product;
}

// print the scraped data
print_r($products);

// release the browser resources
$browser->close();

执行 PHP 脚本,它将在终端中产生以下输出:

Array
(
    [0] => Array
        (
            [name] => Short Dress
            [price] => $24.99
        )

    // omitted for brevity...

    [9] => Array
        (
            [name] => Fitted Dress
            [price] => $34.99
        )

)

太棒了!PHP 解析逻辑按预期工作。剩下的就是将收集的数据导出为更好的格式,例如 CSV。

步骤 4:将数据导出为 CSV

PHP 标准 API 提供了将抓取的数据导出到输出 CSV 文件所需的一切。使用fopen()创建products.csv文件,然后用它填充文件fputcsv()。此命令将产品对象数组转换为 CSV 记录并将其附加到 CSV 文件中。

// open the output CSV file
$csvFilePath = 'products.csv';
$csvFile = fopen($csvFilePath, 'w');

// write the header row
$header = ['name', 'price'];
fputcsv($csvFile, $header);

// add each product to the CSV file
foreach ($products as $product) {
    fputcsv($csvFile, $product);
}

// close the CSV file
fclose($csvFile);

将上述逻辑整合到中scraper.php,你将获得:

<?php

require_once ('vendor/autoload.php');

use Nesk\Puphpeteer\Puppeteer;
use Nesk\Rialto\Data\JsFunction;

// open a new Chromium browser window
$puppeteer = new Puppeteer();
$browser = $puppeteer->launch([
  'headless' => true, // set to false while developing locally
]);

// open a new page in the browser
$page = $browser->newPage();
// visit the target page
$page->goto('https://scrapingclub.com/exercise/list_infinite_scroll/');

// where to store the scraped data
$products = [];

// select all product nodes on the page
$product_elements = $page->querySelectorAll('.post');

// iterate over the product elements and
// apply the scraping logic
foreach ($product_elements as $product_element) {
  // select the name and price elements
  $name_element = $product_element->querySelector('h4');
  $price_element = $product_element->querySelector('h5');

  // retrieve the data of interest
  $name = $name_element->evaluate(JsFunction::createWithParameters(['node'])->body('return node.innerText;'));
  $price = $price_element->evaluate(JsFunction::createWithParameters(['node'])->body('return node.innerText;'));

  // create a new product object and add it to the list
  $product = ['name' => $name, 'price' => $price];
  $products[] = $product;
}

// open the output CSV file
$csvFilePath = 'products.csv';
$csvFile = fopen($csvFilePath, 'w');

// write the header row
$header = ['name', 'price'];
fputcsv($csvFile, $header);

// add each product to the CSV file
foreach ($products as $product) {
    fputcsv($csvFile, $product);
}

// close the CSV file
fclose($csvFile);

// release the browser resources
$browser->close();

启动 Puppeteer PHP 抓取脚本:

php src/scraper.php

脚本执行结束后,products.csv项目根文件夹中会出现一个文件,打开它,你会看到以下记录:

产品 CSV 文件

太棒了!现在您已经了解了在 PHP 中使用 Puppeteer 的基础知识。

如您所见,当前输出仅包含 10 条记录。这是因为页面的初始视图仅包含少量产品,并且依赖无限滚动来加载更多产品。

在下一部分中,您将学习如何处理无限滚动并从网站上的所有产品中提取数据。

通过浏览器自动化与网页交互

PuPHPeteer 可以模拟许多 Web 交互,包括等待、点击等。您需要它们像人类用户一样与动态内容网页进行交互。模仿人类行为还将帮助您的脚本避开反机器人。

Puppeteer PHP 库支持的交互包括:

  1. 点击元素。
  2. 移动鼠标光标。
  3. 等待页面上的元素出现、可见和隐藏。
  4. 在输入字段中键入字符。
  5. 提交表格。
  6. 截屏。 大多数操作都可通过库的内置方法实现。对于其他交互,请使用evaluate()在页面上直接执行 JavaScript 代码。这两种方法涵盖了任何用户交互。

是时候学习如何从无限滚动演示页面抓取所有产品数据,然后模拟其他流行的交互了!

滚动

目标页面在第一次加载后仅包含十种产品,当用户到达视口末尾时会加载更多产品。

Puppeteer 没有自带滚动方法。您需要自定义 JavaScript 脚本来模拟滚动交互并加载新产品。

此 JavaScript 代码片段告诉浏览器以每次 0.5 秒的间隔向下滚动页面十次:

const scrolls = 10
let scrollCount = 0

// scroll down and then wait for 0.5s
const scrollInterval = setInterval(() => {
  window.scrollTo(0, document.body.scrollHeight)
  scrollCount++

  if (scrollCount === scrolls) {
    clearInterval(scrollInterval)
  }
}, 500)
将上述脚本存储在一个多行字符串变量中。然后,使用它初始化 aJSFunction并将其提供给evalutate()的方法$page:

scraper.php
$scrolling_script = <<<EOD
const scrolls = 10
let scrollCount = 0

// scroll down and then wait for 0.5s
const scrollInterval = setInterval(() => {
  window.scrollTo(0, document.body.scrollHeight)
  scrollCount++

  if (scrollCount === numScrolls) {
    clearInterval(scrollInterval)
  }
}, 500)
EOD;

$scrolling_js_function = (new JsFunction())->body($scrolling_script);
$page->evaluate($scrolling_js_function);

笔记 将该指令放在evaluate()节点选择逻辑之前,以确保 DOM 包含所有产品。

Puppeteer 现在将指示 Chromium 向下滚动页面。但是,检索新产品并呈现它们需要时间。等待滚动和数据加载操作以指令结束sleep()。停止脚本执行 10 秒:

sleep(10);
完整代码如下:

scraper.php
<?php

require_once ('vendor/autoload.php');

use Nesk\Puphpeteer\Puppeteer;
use Nesk\Rialto\Data\JsFunction;

// open a new Chromium browser window
$puppeteer = new Puppeteer();
$browser = $puppeteer->launch([
  'headless' => true, // set to false while developing locally
]);

// open a new page in the browser
$page = $browser->newPage();
// visit the target page
$page->goto('https://scrapingclub.com/exercise/list_infinite_scroll/');

// JS script to simulate the infinite scrolling interaction
$scrolling_script = <<<EOD
const scrolls = 10
let scrollCount = 0

// scroll down and then wait for 0.5s
const scrollInterval = setInterval(() => {
  window.scrollTo(0, document.body.scrollHeight)
  scrollCount++

  if (scrollCount === numScrolls) {
    clearInterval(scrollInterval)
  }
}, 500)
EOD;

// execute the JS script on the page
$scrolling_js_function = (new JsFunction())->body($scrolling_script);
$page->evaluate($scrolling_js_function);

// wait 10 seconds for the product nodes to load
sleep(10);

// where to store the scraped data
$products = [];

// select all product nodes on the page
$product_elements = $page->querySelectorAll('.post');

// iterate over the product elements and
// apply the scraping logic
foreach ($product_elements as $product_element) {
  // select the name and price elements
  $name_element = $product_element->querySelector('h4');
  $price_element = $product_element->querySelector('h5');

  // retrieve the data of interest
  $name = $name_element->evaluate(JsFunction::createWithParameters(['node'])->body('return node.innerText;'));
  $price = $price_element->evaluate(JsFunction::createWithParameters(['node'])->body('return node.innerText;'));

  // create a new product object and add it to the list
  $product = ['name' => $name, 'price' => $price];
  $products[] = $product;
}

// open the output CSV file
$csvFilePath = 'products.csv';
$csvFile = fopen($csvFilePath, 'w');

// write the header row
$header = ['name', 'price'];
fputcsv($csvFile, $header);

// add each product to the CSV file
foreach ($products as $product) {
    fputcsv($csvFile, $product);
}

// close the CSV file
fclose($csvFile);

// release the browser resources
$browser->close();

执行脚本来验证它是否检索了全部 60 种产品:

php src/scraper.php

该products.csv文件将包含超过 10 条记录:

任务完成!您刚刚使用 Puppeteer PHP 包从页面抓取了所有产品。

等待元素

目前,PHP Puppeteer 脚本依赖于硬等待来让所有新产品出现在页面上。然而,这种方法是无效的,原因有二:

它会给您的抓取逻辑带来不稳定性,使其容易受到网络或浏览器速度变慢的影响。

它会在固定的秒数内停止执行,从而减慢脚本的速度。 您应该选择智能等待,例如等待特定元素出现在 DOM 上。这是构建强大、一致、可靠的浏览器自动化抓取工具的最佳实践。

PuPHPeteer 提供了waitForSelector()等待节点出现在页面上的功能。使用它最多可以等待 10 秒钟,直到第 60 个产品出现:

$page->waitForSelector('.post:nth-child(60)', ['timeout' => 10000]);

用此行替换该sleep()指令。脚本现在将等待滚动触发的 AJAX 调用后呈现产品节点。

最终的抓取逻辑如下:

<?php
require_once ('vendor/autoload.php');

use Nesk\Puphpeteer\Puppeteer;
use Nesk\Rialto\Data\JsFunction;

// open a new Chromium browser window
$puppeteer = new Puppeteer();
$browser = $puppeteer->launch([
  'headless' => true, // set to false while developing locally
]);

// open a new page in the browser
$page = $browser->newPage();
// visit the target page
$page->goto('https://scrapingclub.com/exercise/list_infinite_scroll/');

// JS script to simulate the infinite scrolling interaction
$scrolling_script = <<<EOD
const scrolls = 10
let scrollCount = 0

// scroll down and then wait for 0.5s
const scrollInterval = setInterval(() => {
  window.scrollTo(0, document.body.scrollHeight)
  scrollCount++

  if (scrollCount === numScrolls) {
    clearInterval(scrollInterval)
  }
}, 500)
EOD;

// execute the JS script on the page
$scrolling_js_function = (new JsFunction())->body($scrolling_script);
$page->evaluate($scrolling_js_function);

// wait up to 10 seconds for the 60th product to be on the page
$page->waitForSelector('.post:nth-child(60)', ['timeout' => 10000]);

// where to store the scraped data
$products = [];

// select all product nodes on the page
$product_elements = $page->querySelectorAll('.post');

// iterate over the product elements and
// apply the scraping logic
foreach ($product_elements as $product_element) {
  // select the name and price elements
  $name_element = $product_element->querySelector('h4');
  $price_element = $product_element->querySelector('h5');

  // retrieve the data of interest
  $name = $name_element->evaluate(JsFunction::createWithParameters(['node'])->body('return node.innerText;'));
  $price = $price_element->evaluate(JsFunction::createWithParameters(['node'])->body('return node.innerText;'));

  // create a new product object and add it to the list
  $product = ['name' => $name, 'price' => $price];
  $products[] = $product;
}

// open the output CSV file
$csvFilePath = 'products.csv';
$csvFile = fopen($csvFilePath, 'w');

// write the header row
$header = ['name', 'price'];
fputcsv($csvFile, $header);

// add each product to the CSV file
foreach ($products as $product) {
    fputcsv($csvFile, $product);
}

// close the CSV file
fclose($csvFile);

// release the browser resources
$browser->close();

运行它。您将以更快的速度获得与以前相同的结果。这是因为脚本现在只会等待适当的时间,从而减少了空闲时间。

等待页面加载

默认情况下,该goto()方法等待页面load在浏览器中触发事件。要更改该行为,您可以将特殊选项数组传递给goto():

$page->goto('https://scrapingclub.com/exercise/list_infinite_scroll/', ['waitUntil' => 'load']);

可能的值包括waitUntil:

  • 'load':等待load事件。
  • 'domcontentloaded':等待DOMContentLoaded事件。
  • 'networkidle0':等待至少 500 毫秒,直到网络连接不超过 0 个。
  • 'networkidle2':等待至少 500 毫秒,直到网络连接不超过两个。

如果您想等待页面导航到新 URL 或重新加载,请使用该waitForNavigation()方法。当您的交互逻辑触发页面更改时,它很有用:

$page->waitForNavigation();

笔记 waitForNavigation()接受具有与之前相同属性的可选选项数组waitUntil。

问题是现代网页非常动态,因此很难判断页面何时完全加载。要处理更复杂的情况,请使用该waitForSelector()方法。它接受具有以下属性的可选数组:

  • hidden:等待选定元素在 DOM 中不存在。默认值:false。
  • signal:对象取消waitForSelector()呼叫的信号。
  • timeout:最大等待时间(毫秒)。默认值:30,000(30 秒)。
  • visible:等待选定的元素在 DOM 中可见并出现。默认值:false。 单击元素
  • Puppeteer PHP 库中的元素对象公开了 click() 方法。调用它来模拟给定元素上的点击交互:
$element->click();

此函数指示 Chromium 点击指定节点。浏览器将发送鼠标点击事件并调用onclick()回调。

当click()调用触发页面更改时(如下面的代码片段所示),您必须等待新页面加载。然后,在新的 DOM 结构上编写一些解析逻辑:

// select a product element and click it
$product_element = $page->querySelector('.post');
$product_element->click();

// wait for the new page to load
$page->waitForNavigation();

// you are now on the detail product page...
    
// new scraping logic...

// $page->querySelectorAll(...);

抓取文本数据并不是从网站获取有用信息的唯一方法。特定页面或 DOM 元素的屏幕截图通常很有用,例如,用于竞争对手研究或测试目的。

PHP Puppeteer 包含screenshot()截取当前视口屏幕截图的方法:

// take a screenshot of the current viewport
$page->screenshot(['path' => 'screenshot.png']);
它会screenshot.png在你的项目的根文件夹中生成一个文件。

screenshot()类似地,您可以在单个元素上调用该方法:

scraper.php
$product_elements[0]->screenshot(['path' => 'product.png']);

它将生成一个product.png包含所选元素的屏幕截图的文件。

干得好!现在您已成为 PHP 版 Puppeteer 中用户交互的大师了。

使用 PHP 中的 Puppeteer 进行抓取时避免被阻止 被反机器人解决方案拦截是使用 Puppeteer 进行网页抓取的最大挑战。保护系统能够判断传入的请求是由人类用户还是机器人(例如您的脚本)发出的。

为了避免阻塞,您必须使请求对目标服务器来说看起来更自然。实现该目标的两种有用技术是:

  • 设置真实世界的用户代理标头
  • 使用代理更改您的出口 IP。 如果您想探索其他方法,请阅读我们的网页抓取指南,避免受到阻碍。

让我们从用户代理开始。通过 Chromium 标志自定义 PHP Puppeteer 中的用户代理--user-agent:

$custom_user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
$puppeteer = new Puppeteer();
$browser = $puppeteer->launch([
  'args' => ['--user-agent=$custom_user_agent'],
  // other options...
]);

在我们的网页抓取用户代理指南中了解有关此方法的更多信息。

设置代理遵循类似的模式,并取决于标志。从Free Proxy List--proxy-server等网站获取免费代理的 URL ,然后将其传递给 Chrmoium,如下所示:

$proxy_url = '233.67.4.11:6879';
$puppeteer = new Puppeteer();
$browser = $puppeteer->launch([
  'args' => ['--proxy-server=$proxy_url'],
  // other options...
]);

笔记 当您阅读本指南时,所选的代理服务器将不再有效。这是因为免费代理占用大量数据、寿命短且不可靠。请仅将它们用于学习目的,切勿将其用于生产。

结论 在本教程中,您学习了在 PHP 中控制 Chromium 的基础知识。您探索了 Puppeteer 的基础知识,然后深入研究了更高级的技术。您已成为 PHP 浏览器自动化专家!

集蜂云(beeize.com)是一个可以让开发者在上面构建、部署、运行、发布采集器的数据采集云平台。平台提供了海量任务调度、三方应用集成、数据存储、监控告警、运行日志查看等功能,能够提供稳定的数据采集环境。平台提供丰富的采集模板,简单配置就可以直接运行,快来试一下吧。

导航目录