教程:使用必应 Web 搜索 API 创建单页应用

警告

2020 年 10 月 30 日,必应搜索 API 从 Azure AI 服务迁移到必应搜索服务。 本文档仅供参考。 有关更新的文档,请参阅必应搜索 API 文档。 关于为必应搜索创建新的 Azure 资源的说明,请参阅通过 Azure 市场创建必应搜索资源

此单页应用演示如何从必应 Web 搜索 API 检索、分析和显示搜索结果。 本教程使用样本 HTML 和 CSS,重点介绍 JavaScript 代码。 GITHub 上提供了 HTML、CSS 和 JS 文件,并提供快速入门说明。

此示例应用可以:

  • 使用搜索选项调用必应“Web”搜索 API
  • 显示 Web、图像、新闻和视频结果
  • 分页结果
  • 管理订阅密钥
  • 处理错误

若要使用此应用,需要使用具有必应搜索 API 的 Azure AI 服务帐户

先决条件

以下是运行该应用所需的一些事项:

第一步是使用示例应用的源代码克隆存储库。

git clone https://github.com/Azure-Samples/cognitive-services-REST-api-samples.git

然后运行 npm install。 对于本教程,Express.js 是唯一的依赖项。

cd <path-to-repo>/cognitive-services-REST-api-samples/Tutorials/bing-web-search
npm install

应用组件

我们构建的示例应用由四个部分组成:

  • bing-web-search.js - 我们的 Express.js 应用。 它处理请求/响应逻辑和路由。
  • public/index.html - 应用框架;它定义如何将数据呈现给用户。
  • public/css/styles.css - 定义页面样式,如字体、颜色、文本大小。
  • public/js/scripts.js - 包含向必应 Web 搜索 API 发出请求、管理订阅密钥、处理和分析响应以及显示结果的逻辑。

本教程强调scripts.js 和调用必应 Web 搜索 API 以及处理响应所需的逻辑。

HTML 表单

其中包括 index.html 一个表单,使用户能够搜索和选择搜索选项。 当表单提交时,属性 onsubmit 将被触发,调用在 bingWebSearch() 中定义的 scripts.js 方法。 它需要三个参数:

  • 搜索请求
  • 所选选项
  • 订阅密钥
<form name="bing" onsubmit="return bingWebSearch(this.query.value,
    bingSearchOptions(this), getSubscriptionKey())">

查询选项

HTML 窗体中的选项映射到 必应 Web 搜索 API v7 的查询参数。 下表详细介绍了如何使用示例应用筛选搜索结果:

参数 说明
query 用于输入查询字符串的文本字段。
where 用于选择市场(位置和语言)的下拉菜单。
what 用于提升特定结果类型的复选框。 例如,提升图像会增加搜索结果中图像的排名。
when 一个下拉菜单,允许用户将搜索结果限制为今天、本周或本月。
safe 启用必应安全搜索的复选框,用于筛除成人内容。
count 隐藏字段。 要在每个请求中返回的搜索结果数。 更改此值以显示每页的更少或更多结果。
offset 隐藏字段。 请求中第一个搜索结果的偏移量,用于分页显示。 每次有新请求时,都会重置为 0

注释

必应 Web 搜索 API 提供其他查询参数来帮助优化搜索结果。 此示例只使用几个。 有关可用参数的完整列表,请参阅 必应 Web 搜索 API v7 参考

bingSearchOptions() 函数转换这些选项以匹配必应搜索 API 所需的格式。

// Build query options from selections in the HTML form.
function bingSearchOptions(form) {

    var options = [];
    // Where option.
    options.push("mkt=" + form.where.value);
    // SafeSearch option.
    options.push("SafeSearch=" + (form.safe.checked ? "strict" : "moderate"));
    // Freshness option.
    if (form.when.value.length) options.push("freshness=" + form.when.value);
    var what = [];
    for (var i = 0; i < form.what.length; i++)
        if (form.what[i].checked) what.push(form.what[i].value);
    // Promote option.
    if (what.length) {
        options.push("promote=" + what.join(","));
        options.push("answerCount=9");
    }
    // Count option.
    options.push("count=" + form.count.value);
    // Offset option.
    options.push("offset=" + form.offset.value);
    // Hardcoded text decoration option.
    options.push("textDecorations=true");
    // Hardcoded text format option.
    options.push("textFormat=HTML");
    return options.join("&");
}

SafeSearch 可以设置为 strictmoderateoff,其中 moderate 是必应 Web 搜索的默认设置。 此窗体使用一个复选框,其中包含两种状态: strictmoderate

如果选择了任何 “升级 ”复选框,则参数 answerCount 将添加到查询中。 answerCount 使用 promote 参数时是必需的。 在此代码片段中,该值设置为 9 返回所有可用的结果类型。

注释

提升结果类型不能保证它会被包含在搜索结果中。 相反,提升排名会使这类结果的排名相对其平时的排名提高。 若要将搜索限制为特定类型的结果,请使用 responseFilter 查询参数,或调用更具体的终结点,例如必应图像搜索或必应新闻搜索。

查询参数textDecorationtextFormat被硬编码到脚本中,这会导致搜索词在搜索结果中以加粗形式显示。 不需要这些参数。

管理订阅密钥

为了避免对必应搜索 API 订阅密钥进行硬编码,此示例应用使用浏览器的持久存储来存储订阅密钥。 如果未存储任何订阅密钥,系统会提示用户输入一个。 如果 API 拒绝订阅密钥,系统会提示用户重新输入订阅密钥。

getSubscriptionKey() 函数使用 storeValueretrieveValue 函数来存储和检索用户的订阅密钥。 这些函数使用 localStorage 对象(如果受支持)或 Cookie。

// Cookie names for stored data.
API_KEY_COOKIE   = "bing-search-api-key";
CLIENT_ID_COOKIE = "bing-search-client-id";

BING_ENDPOINT = "https://api.cognitive.microsoft.com/bing/v7.0/search";

// See source code for storeValue and retrieveValue definitions.

// Get stored subscription key, or prompt if it isn't found.
function getSubscriptionKey() {
    var key = retrieveValue(API_KEY_COOKIE);
    while (key.length !== 32) {
        key = prompt("Enter Bing Search API subscription key:", "").trim();
    }
    // Always set the cookie in order to update the expiration date.
    storeValue(API_KEY_COOKIE, key);
    return key;
}

正如我们之前看到的,当表单提交时, onsubmit 触发,呼叫 bingWebSearch。 此函数初始化并发送请求。 getSubscriptionKey 在每个提交中调用 ,以对请求进行身份验证。

给定查询、选项字符串和订阅密钥,该 BingWebSearch 函数将创建一个 XMLHttpRequest 对象来调用必应 Web 搜索终结点。

// Perform a search constructed from the query, options, and subscription key.
function bingWebSearch(query, options, key) {
    window.scrollTo(0, 0);
    if (!query.trim().length) return false;

    showDiv("noresults", "Working. Please wait.");
    hideDivs("pole", "mainline", "sidebar", "_json", "_http", "paging1", "paging2", "error");

    var request = new XMLHttpRequest();
    var queryurl = BING_ENDPOINT + "?q=" + encodeURIComponent(query) + "&" + options;

    // Initialize the request.
    try {
        request.open("GET", queryurl);
    }
    catch (e) {
        renderErrorMessage("Bad request (invalid URL)\n" + queryurl);
        return false;
    }

    // Add request headers.
    request.setRequestHeader("Ocp-Apim-Subscription-Key", key);
    request.setRequestHeader("Accept", "application/json");
    var clientid = retrieveValue(CLIENT_ID_COOKIE);
    if (clientid) request.setRequestHeader("X-MSEdge-ClientID", clientid);

    // Event handler for successful response.
    request.addEventListener("load", handleBingResponse);

    // Event handler for errors.
    request.addEventListener("error", function() {
        renderErrorMessage("Error completing request");
    });

    // Event handler for an aborted request.
    request.addEventListener("abort", function() {
        renderErrorMessage("Request aborted");
    });

    // Send the request.
    request.send();
    return false;
}

成功请求后, load 事件处理程序将触发并调用该 handleBingResponse 函数。 handleBingResponse 分析结果对象,显示结果,并包含失败请求的错误逻辑。

function handleBingResponse() {
    hideDivs("noresults");

    var json = this.responseText.trim();
    var jsobj = {};

    // Try to parse results object.
    try {
        if (json.length) jsobj = JSON.parse(json);
    } catch(e) {
        renderErrorMessage("Invalid JSON response");
        return;
    }

    // Show raw JSON and the HTTP request.
    showDiv("json", preFormat(JSON.stringify(jsobj, null, 2)));
    showDiv("http", preFormat("GET " + this.responseURL + "\n\nStatus: " + this.status + " " +
        this.statusText + "\n" + this.getAllResponseHeaders()));

    // If the HTTP response is 200 OK, try to render the results.
    if (this.status === 200) {
        var clientid = this.getResponseHeader("X-MSEdge-ClientID");
        if (clientid) retrieveValue(CLIENT_ID_COOKIE, clientid);
        if (json.length) {
            if (jsobj._type === "SearchResponse" && "rankingResponse" in jsobj) {
                renderSearchResults(jsobj);
            } else {
                renderErrorMessage("No search results in JSON response");
            }
        } else {
            renderErrorMessage("Empty response (are you sending too many requests too quickly?)");
        }
    }

    // Any other HTTP response is considered an error.
    else {
        // 401 is unauthorized; force a re-prompt for the user's subscription
        // key on the next request.
        if (this.status === 401) invalidateSubscriptionKey();

        // Some error responses don't have a top-level errors object, if absent
        // create one.
        var errors = jsobj.errors || [jsobj];
        var errmsg = [];

        // Display the HTTP status code.
        errmsg.push("HTTP Status " + this.status + " " + this.statusText + "\n");

        // Add all fields from all error responses.
        for (var i = 0; i < errors.length; i++) {
            if (i) errmsg.push("\n");
            for (var k in errors[i]) errmsg.push(k + ": " + errors[i][k]);
        }

        // Display Bing Trace ID if it isn't blocked by CORS.
        var traceid = this.getResponseHeader("BingAPIs-TraceId");
        if (traceid) errmsg.push("\nTrace ID " + traceid);

        // Display the error message.
        renderErrorMessage(errmsg.join("\n"));
    }
}

重要

成功的 HTTP 请求 并不意味着 搜索本身成功。 如果在搜索作中出错,必应 Web 搜索 API 将返回非 200 HTTP 状态代码,并在 JSON 响应中包含错误信息。 如果请求速率有限,API 将返回空响应。

上述两个函数中的大部分代码都专用于错误处理。 错误可能在以下阶段发生:

阶段 可能的错误 由...处理
生成请求对象 URL 无效 try / catch
发出请求 网络错误、已中止的连接 errorabort 事件处理程序
进行搜索 请求无效、JSON 无效、速率限制 事件处理程序中的 load 测试

通过调用 renderErrorMessage()来处理错误。 如果响应通过所有错误测试, renderSearchResults() 则调用它以显示搜索结果。

显示搜索结果

必应 Web 搜索 API 返回的结果 有使用和显示要求 。 由于响应可能包含各种结果类型,因此它不足以循环访问顶级 WebPages 集合。 相反,示例应用使用 RankingResponse 来按规范对结果进行排序。

注释

如果只需要单个结果类型,请使用 responseFilter 查询参数,或考虑使用其他必应搜索终结点之一,例如必应图像搜索。

每个响应都有一个对象,该对象最多包含三个 RankingResponse 集合: polemainlinesidebarpole(如果存在)是最相关的搜索结果,必须突出显示。 mainline 包含大部分搜索结果,并紧接着 pole显示。 sidebar 包括辅助搜索结果。 如果可能,应在边栏中显示这些结果。 如果屏幕限制使边栏不切实际,这些结果应显示在 mainline 结果之后。

每个 RankingResponse 数组都包含一个 RankingItem 数组,用于指定结果的排序方式。 我们的示例应用使用 answerTyperesultIndex 参数来标识结果。

注释

可通过其他方法来识别和排名结果。 有关详细信息,请参阅 “使用排名显示结果”。

让我们看看代码:

// Render the search results from the JSON response.
function renderSearchResults(results) {

    // If spelling was corrected, update the search field.
    if (results.queryContext.alteredQuery)
        document.forms.bing.query.value = results.queryContext.alteredQuery;

    // Add Prev / Next links with result count.
    var pagingLinks = renderPagingLinks(results);
    showDiv("paging1", pagingLinks);
    showDiv("paging2", pagingLinks);

    // Render the results for each section.
    for (section in {pole: 0, mainline: 0, sidebar: 0}) {
        if (results.rankingResponse[section])
            showDiv(section, renderResultsItems(section, results));
    }
}

renderResultsItems() 函数循环访问每个 RankingResponse 集合中的项,使用 answerTyperesultIndex 值将每个排名结果映射到搜索结果,并调用相应的呈现函数来生成 HTML。 如果未为某项指定 resultIndex,则 renderResultsItems() 会遍历该类型的所有结果,并为每个项目调用呈现函数。 生成的 HTML 被插入到 <div> 中的相应 index.html 元素。

// Render search results from the RankingResponse object per rank response and
// use and display requirements.
function renderResultsItems(section, results) {

    var items = results.rankingResponse[section].items;
    var html = [];
    for (var i = 0; i < items.length; i++) {
        var item = items[i];
        // Collection name has lowercase first letter while answerType has uppercase
        // e.g. `WebPages` RankingResult type is in the `webPages` top-level collection.
        var type = item.answerType[0].toLowerCase() + item.answerType.slice(1);
        if (type in results && type in searchItemRenderers) {
            var render = searchItemRenderers[type];
            // This ranking item refers to ONE result of the specified type.
            if ("resultIndex" in item) {
                html.push(render(results[type].value[item.resultIndex], section));
            // This ranking item refers to ALL results of the specified type.
            } else {
                var len = results[type].value.length;
                for (var j = 0; j < len; j++) {
                    html.push(render(results[type].value[j], section, j, len));
                }
            }
        }
    }
    return html.join("\n\n");
}

查看呈现器函数

在我们的示例应用中,该 searchItemRenderers 对象包括为每种搜索结果类型生成 HTML 的函数。

// Render functions for each result type.
searchItemRenderers = {
    webPages: function(item) { ... },
    news: function(item) { ... },
    images: function(item, section, index, count) { ... },
    videos: function(item, section, index, count) { ... },
    relatedSearches: function(item, section, index, count) { ... }
}

重要

示例应用包含网页、新闻、图像、视频和相关搜索的呈现器。 应用程序将需要呈现器来获取它可能收到的任何类型的结果,其中包括计算、拼写建议、实体、时区和定义。

某些呈现函数仅接受 item 参数。 其他人接受其他参数,这些参数可用于根据上下文以不同的方式呈现项。 不使用此信息的呈现器不需要接受这些参数。

上下文参数为:

参数 说明
section 项目出现的结果部分(polemainlinesidebar)。
index
count
RankingResponse 项指定显示给定集合中的所有结果时可用;否则为 undefined。 项目在其集合中的索引和该集合中的项目总数。 可以使用此信息对结果进行编号,为第一个或最后一个结果生成不同的 HTML,等等。

在示例应用中,imagesrelatedSearches 两个呈现器都使用上下文参数来自定义生成的 HTML。 让我们仔细看看 images 渲染器:

searchItemRenderers = {
    // Render image result with thumbnail.
    images: function(item, section, index, count) {
        var height = 60;
        var width = Math.round(height * item.thumbnail.width / item.thumbnail.height);
        var html = [];
        if (section === "sidebar") {
            if (index) html.push("<br>");
        } else {
            if (!index) html.push("<p class='images'>");
        }
        html.push("<a href='" + item.hostPageUrl + "'>");
        var title = escape(item.name) + "\n" + getHost(item.hostPageDisplayUrl);
        html.push("<img src='"+ item.thumbnailUrl + "&h=" + height + "&w=" + width +
            "' height=" + height + " width=" + width + " title='" + title + "' alt='" + title + "'>");
        html.push("</a>");
        return html.join("");
    },
    // Other renderers are omitted from this sample...
}

图像呈现器:

  • 计算图像缩略图大小(宽度变化,而高度固定为 60 像素)。
  • 根据上下文插入图像结果之前的HTML代码。
  • 生成链接到包含图像的页面的 HTML <a> 标记。
  • 生成 HTML <img> 标记以显示图像缩略图。

图像呈现器使用 sectionindex 变量根据结果的显示位置而有所不同。 在边栏的图像结果之间插入换行符(<br> 标记),以便边栏显示为一列图像。 在其他部分中,标记 (index === 0) 出现在第一个图像结果 <p> 之前。

缩略图大小用于 <img> 标签以及缩略图 URL 中的 hw 字段。 属性titlealt(图像的文本说明)是从图像的名称和 URL 中的主机名构造的。

下面是如何在示例应用中显示图像的示例:

[必应图像结果]

保留客户端 ID

来自必应搜索 API 的响应可能包括一个 X-MSEdge-ClientID 标头,该标头应随每个后续请求一起发送回 API。 如果应用使用了多个必应搜索 API,请确保在服务之间为每个请求发送相同的客户端 ID。

提供X-MSEdge-ClientID标头可以使必应 API 关联到用户的搜索记录。 首先,它允许必应搜索引擎将过去的上下文应用于搜索,以查找更好地满足请求的结果。 例如,如果用户之前搜索了与航行相关的术语,则以后搜索“结”可能会优先返回有关帆船中使用的结的信息。 其次,必应可能会随机挑选用户来体验新功能,这发生在它们被广泛使用之前。 为每个请求提供相同的客户端 ID 可确保选择查看功能的用户将始终看到该功能。 如果没有客户端 ID,用户可能会在搜索结果中看到一个功能出现并消失(看似随机)。

浏览器安全策略(如跨域资源共享(CORS)可能会阻止示例应用访问 X-MSEdge-ClientID 标头。 当搜索响应的来源与请求该响应的页面不同时,就会出现此限制。 在生产环境中,应通过托管在与网页相同的域上执行 API 调用的服务器端脚本来解决此策略。 由于脚本的源与网页相同,X-MSEdge-ClientID 标头随后可供 JavaScript 使用。

注释

在生产 Web 应用程序中,在任何情况下都应执行服务器端请求。 否则,必应搜索 API 的订阅密钥必须包含在网页中,任何查看源代码的用户都可以看到。 API 订阅密钥下的所有使用情况(甚至未经授权的各方发出的请求)都会向你计费,因此请务必不公开密钥。

出于开发目的,可以通过 CORS 代理发出请求。 此类型的代理的响应具有一个 Access-Control-Expose-Headers 标头,用于筛选响应标头并使其可用于 JavaScript。

可以轻松安装 CORS 代理,以允许示例应用访问客户端 ID 标头。 运行以下命令:

npm install -g cors-proxy-server

接下来,将必应网页搜索的端点 script.js 更改为:

http://localhost:9090/https://api.cognitive.microsoft.com/bing/v7.0/search

使用以下命令启动 CORS 代理:

cors-proxy-server

使用示例应用时将命令窗口保持打开状态;关闭窗口会停止代理。 在搜索结果下方的可展开 HTTP 标头部分中, X-MSEdge-ClientID 标头应可见。 验证每个请求是否相同。

后续步骤