教程:单页 Web 应用

警告

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

必应实体搜索 API 允许搜索 Web 以获取有关 实体位置的信息。 可以在给定的查询中请求任一类型的结果或两者。 下面提供了位置和实体的定义。

结果 说明
实体 按名字找到的知名人士、地点和事物
地点 按名称 类型查找的餐馆、酒店和其他当地企业(意大利餐馆)

在本教程中,我们将构建一个单页 Web 应用程序,该应用程序使用必应实体搜索 API 在页面中显示搜索结果。 该应用程序包括 HTML、CSS 和 JavaScript 组件。

API 允许按位置确定结果的优先级。 在移动应用中,您可以请求设备获取其位置。 在 Web 应用中,可以使用函数 getPosition() 。 但此调用仅适用于安全上下文,并且可能不提供精确的位置。 此外,用户可能想要在除自己位置之外的地点附近搜索实体。

因此,我们的应用调用必应地图服务从用户输入的位置获取纬度和经度。 然后,用户可以输入地标(“空格针”)或完整或部分地址(“纽约市”)的名称,必应地图 API 提供坐标。

注释

单击后,页面底部的 JSON 和 HTTP 标题会显示 JSON 响应和 HTTP 请求信息。 在浏览服务时,这些详细信息非常有用。

本教程应用演示了如何:

  • 在 JavaScript 中执行必应实体搜索 API 调用
  • 在 JavaScript 中执行必应地图 locationQuery API 调用
  • 将搜索选项传递给 API 调用
  • 显示搜索结果
  • 管理必应客户端标识和 API 订阅密钥
  • 处理可能发生的任何错误

教程页完全是自包含的;它不使用任何外部框架、样式表甚至图像文件。 它仅使用广受支持的 JavaScript 语言功能,并且适用于所有主要 Web 浏览器的当前版本。

在本教程中,我们仅讨论源代码的选定部分。 完整的源代码可在 单独的页面上使用。 将此代码复制并粘贴到文本编辑器中,并将其另存为 bing.html

注释

本教程与 单页必应 Web 搜索应用教程大致类似,但只处理实体搜索结果。

先决条件

若要遵循本教程,需要必应搜索 API 和必应地图 API 的订阅密钥。

应用组件

与任何单页 Web 应用一样,教程应用程序包括三个部分:

  • HTML - 定义页面的结构和内容
  • CSS - 定义页面的外观
  • JavaScript - 定义页面的行为

本教程不详细介绍大部分 HTML 或 CSS,因为它们非常简单。

HTML 包含用户在其中输入查询并选择搜索选项的搜索表单。 表单通过 <form> 属性连接到实际上是由 onsubmit 标签执行搜索的 JavaScript。

<form name="bing" onsubmit="return newBingEntitySearch(this)">

onsubmit 处理程序返回 false,使表单无法提交到服务器。 JavaScript 代码实际执行从表单收集必要信息和执行搜索的工作。

搜索分为两个阶段。 首先,如果用户输入了位置限制,则必应地图查询会将其转换为坐标。 此查询的回调函数随后会启动必应实体搜索查询。

HTML 还包含显示搜索结果的分区(HTML <div> 标记)。

管理订阅密钥

注释

此应用需要必应搜索 API 和必应地图 API 的订阅密钥。

为了避免在代码中包含必应搜索和必应地图 API 订阅密钥,我们使用浏览器的持久存储来存储它们。 如果任一密钥尚未存储,我们会提示它,并存储它供以后使用。 如果密钥后来被 API 拒绝,我们会使存储的密钥失效,以便在下次搜索时要求用户提供密钥。

我们定义 storeValueretrieveValue 函数,这些函数使用 localStorage 对象(如果浏览器支持)或 cookie。 我们的 getSubscriptionKey() 函数使用这些函数来存储和检索用户的密钥。 可以使用下面的全局终结点,也可以使用 Azure 门户中为资源显示的 自定义子域 终结点。

// cookie names for data we store
SEARCH_API_KEY_COOKIE = "bing-search-api-key";
MAPS_API_KEY_COOKIE   = "bing-maps-api-key";
CLIENT_ID_COOKIE      = "bing-search-client-id";

// API endpoints
SEARCH_ENDPOINT = "https://api.cognitive.microsoft.com/bing/v7.0/entities";
MAPS_ENDPOINT   = "https://dev.virtualearth.net/REST/v1/Locations";

// ... omitted definitions of storeValue() and retrieveValue()

// get stored API subscription key, or prompt if it's not found
function getSubscriptionKey(cookie_name, key_length, api_name) {
    var key = retrieveValue(cookie_name);
    while (key.length !== key_length) {
        key = prompt("Enter " + api_name + " API subscription key:", "").trim();
    }
    // always set the cookie in order to update the expiration date
    storeValue(cookie_name, key);
    return key;
}

function getMapsSubscriptionKey() {
    return getSubscriptionKey(MAPS_API_KEY_COOKIE, 64, "Bing Maps");
}

function getSearchSubscriptionKey() {
    return getSubscriptionKey(SEARCH_API_KEY_COOKIE, 32, "Bing Search");
}

HTML <body> 标记包含一个 onload 属性,该属性在页面加载完成后调用 getSearchSubscriptionKey()getMapsSubscriptionKey()。 这些调用用于在用户尚未输入密钥时立即提示用户输入密钥。

<body onload="document.forms.bing.query.focus(); getSearchSubscriptionKey(); getMapsSubscriptionKey();">

选择搜索选项

[必应实体搜索表单]

HTML 窗体包含以下控件:

控制 说明
where 用于选择用于搜索的市场(位置和语言)的下拉菜单。
query 要在其中输入搜索词的文本字段。
safe 一个复选框,指示是否打开 SafeSearch(限制“成人”结果)
what 用于选择搜索实体、位置或同时搜索的菜单。
mapquery 用户可以在其中输入完整或部分地址、地标等的文本字段,以帮助必应实体搜索返回更相关的结果。

注释

位置结果目前仅在美国可用。 wherewhat菜单中有用于强制实施此限制的代码。 如果在 what 菜单中选择了“位置”时选择非美国市场,what 更改为“任何”。 如果在选择非美国市场时选择了where“地点”,where将更改为美国。

我们的 JavaScript 函数 bingSearchOptions() 将这些字段转换为必应搜索 API 的部分查询字符串。

// build query options from the HTML form
function bingSearchOptions(form) {

    var options = [];
    options.push("mkt=" + form.where.value);
    options.push("SafeSearch=" + (form.safe.checked ? "strict" : "off"));
    if (form.what.selectedIndex) options.push("responseFilter=" + form.what.value);
    return options.join("&");
}

例如,SafeSearch 功能可以是 strictmoderateoff,其中 moderate 是默认值。 但是我们的表单使用一个复选框,该复选框只有两种状态。 JavaScript 代码将此设置转换为或 strictoff (我们不使用 moderate)。

由于 mapquery 字段用于必应地图位置查询,而不是用于必应实体搜索,因此未在 bingSearchOptions() 中处理。

获取位置

必应地图 API 提供了一种方法locationQuery,用于查找用户输入的位置的纬度和经度。 然后,这些坐标会随用户的请求传递到必应实体搜索 API。 搜索结果确定靠近指定位置的实体和位置的优先级。

我们无法在 Web 应用中使用普通 XMLHttpRequest 查询来访问必应地图 API,因为该服务不支持跨域查询。 幸运的是,它支持 JSONP(“P”代表“填充”)。 JSONP 响应是在函数调用中包装的普通 JSON 响应。 请求是通过使用 <script> 标记插入文档发出的。 (加载脚本不受浏览器安全策略的约束。

bingMapsLocate() 函数创建并插入 <script> 查询的标记。 jsonp=bingMapsCallback查询字符串的段指定要通过响应调用的函数的名称。

function bingMapsLocate(where) {

    where = where.trim();
    var url = MAPS_ENDPOINT + "?q=" + encodeURIComponent(where) + 
                "&jsonp=bingMapsCallback&maxResults=1&key=" + getMapsSubscriptionKey();

    var script = document.getElementById("bingMapsResult")
    if (script) script.parentElement.removeChild(script);

    // global variable holds reference to timer that will complete the search if the maps query fails
    timer = setTimeout(function() {
        timer = null;
        var form = document.forms.bing;
        bingEntitySearch(form.query.value, "", bingSearchOptions(form), getSearchSubscriptionKey());
    }, 5000);

    script = document.createElement("script");
    script.setAttribute("type", "text/javascript");
    script.setAttribute("id", "bingMapsResult");
    script.setAttribute("src", url);
    script.setAttribute("onerror", "BingMapsCallback(null)");
    document.body.appendChild(script);

    return false;
}

注释

如果必应地图 API 未响应,则永远不会调用该 bingMapsCallBack() 函数。 通常,这意味着 bingEntitySearch() 不会调用,实体搜索结果不会显示。 为了避免这种情况, bingMapsLocate() 还设置一个计时器,以在五秒后调用 bingEntitySearch() 。 回调函数中有一个逻辑,以避免执行实体搜索两次。

查询完成后, bingMapsCallback() 将按请求调用该函数。

function bingMapsCallback(response) {

    if (timer) {    // we beat the timer; stop it from firing
        clearTimeout(timer);
        timer = null;
    } else {        // the timer beat us; don't do anything
        return; 
    }

    var ___location = "";
    var name = "";
    var radius = 1000;

    if (response) {
        try {
            if (response.statusCode === 401) {
                invalidateMapsKey();
            } else if (response.statusCode === 200) {
                var resource = response.resourceSets[0].resources[0];
                var coords   = resource.point.coordinates;
                name         = resource.name;

                // the radius is the largest of the distances between the ___location and the corners
                // of its bounding box (in case it's not in the center) with a minimum of 1 km
                try {
                    var bbox    = resource.bbox;
                    radius  = Math.max(haversineDistance(bbox[0], bbox[1], coords[0], coords[1]),
                                       haversineDistance(coords[0], coords[1], bbox[2], bbox[1]),
                                       haversineDistance(bbox[0], bbox[3], coords[0], coords[1]),
                                       haversineDistance(coords[0], coords[1], bbox[2], bbox[3]), 1000);
                } catch(e) {  }
                var ___location = "lat:" + coords[0] + ";long:" + coords[1] + ";re:" + Math.round(radius);
            }
        }
        catch (e) { }   // response is unexpected. this isn't fatal, so just don't provide ___location
    }

    var form = document.forms.bing;
    if (name) form.mapquery.value = name;
    bingEntitySearch(form.query.value, ___location, bingSearchOptions(form), getSearchSubscriptionKey());

}

除了纬度和经度,必应实体搜索查询还需要一个 半径 ,指示位置信息的精度。 我们使用必应地图响应中提供的 边界框 计算半径。 边界框是包围整个对象的矩形。 例如,如果用户输入 NYC,结果将包含纽约市的大致中心坐标以及一个围绕该城市的边界框。

我们首先使用函数 haversineDistance() 计算从主坐标到边界框的四个角之间的距离(未显示)。 我们将这四个距离中最大的一个用作半径。 最小半径为一公里。 如果在响应中未提供边界框,则此值也用作默认值。

获取坐标和半径后,我们调用 bingEntitySearch() 执行实际搜索。

鉴于查询、位置、选项字符串和 API 密钥,函数 BingEntitySearch() 发出必应实体搜索请求。

// perform a search given query, ___location, options string, and API keys
function bingEntitySearch(query, latlong, options, key) {

    // scroll to top of window
    window.scrollTo(0, 0);
    if (!query.trim().length) return false;     // empty query, do nothing

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

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

    // open 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);

    if (latlong) request.setRequestHeader("X-Search-Location", latlong);

    // event handler for successful response
    request.addEventListener("load", handleBingResponse);
    
    // event handler for erorrs
    request.addEventListener("error", function() {
        renderErrorMessage("Error completing request");
    });

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

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

成功完成 HTTP 请求后,JavaScript 会调用 load 事件处理程序(函数 handleBingResponse() )来处理对 API 的成功 HTTP GET 请求。

// handle Bing search request results
function handleBingResponse() {
    hideDivs("noresults");

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

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

    // show raw JSON and 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 HTTP response is 200 OK, try to render search 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") {
                renderSearchResults(jsobj);
            } else {
                renderErrorMessage("No search results in JSON response");
            }
        } else {
            renderErrorMessage("Empty response (are you sending too many requests too quickly?)");
        }
    if (divHidden("pole") && divHidden("mainline") && divHidden("sidebar")) 
        showDiv("noresults", "No results.<p><small>Looking for restaurants or other local businesses? Those currently areen't supported outside the US.</small>");
    }

    // Any other HTTP status is an error
    else {
        // 401 is unauthorized; force re-prompt for API key for next request
        if (this.status === 401) invalidateSearchKey();

        // some error responses don't have a top-level errors object, so gin one up
        var errors = jsobj.errors || [jsobj];
        var errmsg = [];

        // display 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]);
        }

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

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

重要

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

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

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

通过调用 renderErrorMessage() 来处理错误,其中包含有关错误的任何已知详细信息。 如果响应通过考验所有错误测试,我们将调用 renderSearchResults() 来在页面中显示搜索结果。

显示搜索结果

必应实体搜索 API 要求按指定顺序显示结果。 由于 API 可能会返回两种不同类型的响应,因此它不足以循环访问 JSON 响应中的顶级 EntitiesPlaces 集合并显示这些结果。 (如果只需要一种类型的结果,请使用 responseFilter 查询参数。

相反,我们使用 rankingResponse 搜索结果中的集合对结果进行排序以供显示。 此对象引用Entitiess和/或Places集合中的项。

rankingResponse最多可以包含三个搜索结果集合,指定polemainlinesidebar

pole如果存在,则为最相关的搜索结果,应突出显示。 mainline 指大部分搜索结果。 主线结果应立即显示在pole之后(或如果pole不存在,则在第一个之后)。

终于。 sidebar 指辅助搜索结果。 它们可能显示在真正的边栏中,或者直接显示在主线结果之后。 我们为教程应用选择了后者。

集合中的每个 rankingResponse 项都以两种不同的但等效的方式引用实际搜索结果项。

条目 说明
id id 看起来像一个 URL,但不应用作链接。 排名结果的类型id与答案集合中的搜索结果项id整个答案集合(例如)匹配。
answerType
resultIndex
answerType 指的是包含结果的顶级答案集合(例如,Entities)。 resultIndex 指的是该集合中的结果索引。 如果 resultIndex 省略,排名结果将引用整个集合。

注释

有关搜索响应的此部分的详细信息,请参阅 “排名结果”。

您可以使用任何对您的应用程序最方便的方法来查找所引用的搜索结果项。 在教程代码中,我们使用 answerTyperesultIndex 查找每个搜索结果。

最后,是时候看看我们的函数 renderSearchResults()了。 此函数遍历表示搜索结果三个部分的三个 rankingResponse 集合。 对于每个部分,我们调用 renderResultsItems() 呈现该部分的结果。

// render the search results given the parsed JSON response
function renderSearchResults(results) {

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

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

呈现结果条目

在我们的 JavaScript 代码中是一个对象, searchItemRenderers其中包含 呈现器: 为每种搜索结果生成 HTML 的函数。

searchItemRenderers = { 
    entities: function(item) { ... },
    places: function(item) { ... }
}

呈现器函数可以接受以下参数:

参数 说明
item 包含项属性的 JavaScript 对象,例如其 URL 及其说明。
index 其集合中结果项的索引。
count 搜索结果项集合中的项数。

indexcount 参数可用于对结果进行编号、为集合的开头或结尾生成特殊 HTML、在一定数量的项之后插入换行符等。 如果呈现器不需要此功能,则不需要接受这两个参数。 事实上,我们不会在教程应用的呈现器中使用它们。

让我们仔细看看 entities 渲染器:

    entities: function(item) {
        var html = [];
        html.push("<p class='entity'>");
        if (item.image) {
            var img = item.image;
            if (img.hostPageUrl) html.push("<a href='" + img.hostPageUrl + "'>");
            html.push("<img src='" + img.thumbnailUrl +  "' title='" + img.name + "' height=" + img.height + " width= " + img.width + ">");
            if (img.hostPageUrl) html.push("</a>");
            if (img.provider) {
                var provider = img.provider[0];
                html.push("<small>Image from ");
                if (provider.url) html.push("<a href='" + provider.url + "'>");
                html.push(provider.name ? provider.name : getHost(provider.url));
                if (provider.url) html.push("</a>");
                html.push("</small>");
            }
        }
        html.push("<p>");
        if (item.entityPresentationInfo) {
            var pi = item.entityPresentationInfo;
            if (pi.entityTypeHints || pi.entityTypeDisplayHint) {
                html.push("<i>");
                if (pi.entityTypeDisplayHint) html.push(pi.entityTypeDisplayHint);
                else if (pi.entityTypeHints) html.push(pi.entityTypeHints.join("/"));
                html.push("</i> - ");
            }
        }
        html.push(item.description);
        if (item.webSearchUrl) html.push("&nbsp;<a href='" + item.webSearchUrl + "'>More</a>")
        if (item.contractualRules) {
            html.push("<p><small>");
            var rules = [];
            for (var i = 0; i < item.contractualRules.length; i++) {
                var rule = item.contractualRules[i];
                var link = [];
                if (rule.license) rule = rule.license;
                if (rule.url) link.push("<a href='" + rule.url + "'>");
                link.push(rule.name || rule.text || rule.targetPropertyName + " source");
                if (rule.url) link.push("</a>");
                rules.push(link.join(""));
            }
            html.push("License: " + rules.join(" - "));
            html.push("</small>");
        }
        return html.join("");
    }, // places renderer omitted

实体渲染器函数:

  • 生成 HTML <img> 标记以显示图像缩略图(如果有)。
  • 生成链接到包含图像的页面的 HTML <a> 标记。
  • 生成显示有关图像及其所在站点信息的说明。
  • 结合显示提示(如果有)对实体的分类进行整合。
  • 包括必应搜索的链接,以获取有关实体的详细信息。
  • 显示数据源所需的任何许可或归属信息。

持续保存客户端 ID

来自必应搜索 API 的响应可能包括一个 X-MSEdge-ClientID 标头,该标头应随后续请求发送回 API。 如果使用多个必应搜索 API,则应将相同的客户端 ID 与所有这些 API 一起使用(如果可能)。

X-MSEdge-ClientID提供标头可让必应 API 关联所有用户的搜索,这有两个重要优势。

首先,它允许必应搜索引擎将过去的上下文应用于搜索,以查找更能满足用户的结果。 例如,如果用户之前搜索了与航行相关的术语,则以后搜索“码头”可能会优先返回有关停靠帆船的位置的信息。

其次,必应可能会随机挑选用户来体验新功能,这发生在它们被广泛使用之前。 为每个请求提供相同的客户端 ID 可确保选择查看功能的用户始终看到该功能。 如果没有客户端 ID,用户可能会在搜索结果中看到一个功能出现并消失(看似随机)。

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

注释

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

出于开发目的,可以通过 CORS 代理发起微软必应 Web 搜索 API 请求。 此类代理的响应具有一个 Access-Control-Expose-Headers 标头,该标头允许列出响应标头,并使其可用于 JavaScript。

可以轻松安装 CORS 代理,以允许我们的教程应用访问客户端 ID 标头。 首先,如果尚未安装,安装 Node.js。 然后在命令窗口中发出以下命令:

npm install -g cors-proxy-server

接下来,将 HTML 文件中的必应 Web 搜索终结点更改为:
http://localhost:9090/https://api.cognitive.microsoft.com/bing/v7.0/search

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

cors-proxy-server

使用教程应用时,使命令窗口保持打开状态;关闭窗口会停止代理。 在搜索结果下方的可展开 HTTP 标头部分中,现在可以看到 X-MSEdge-ClientID 标头(等等),并验证每个请求是否相同。

后续步骤