Google Search 淘气三千问: Q6

前言见:Google Search 淘气三千问: Q1~Q5

Q6: Google 为站点设计了哪些特征?

在 Google ContentWarehouse API 中有很多字段以 nsr 开头,有人说它代表 Neural Search Ranking,我觉得这种说法不对,因为 nsrDataProto 字段的注释是 Stripped site-level signals:

GoogleApi.ContentWarehouse.V1.Model.PerDocData

* nsrDataProto (type: GoogleApi.ContentWarehouse.V1.Model.QualityNsrNsrData.t, default: nil) - Stripped site-level signals, not present in the explicit nsr_* fields, nor compressed_quality_signals.

说明 NSR 应该是站点信号,考虑到 QualityNsrNsrData 中还有一个 nsr 字段,我猜测应该是 New Site Rank,或者 Normalized Site Rank。也许这个字段以前只是一个简单的代表站点质量的信号,后来扩展成了一系列信号的组合,但是沿用了 nsr 这个前缀。

其中字段注释非常简单,这里我尝试把他们汇编并且扩展解读一下,看看 Google 的站点特征体系都包括哪些。

首先要解释一点,Nsr 虽然是站点信号,但是它的信号汇聚粒度并不全是站点范围,而是有个 sitechunk 的概念。我猜测 sitechunk 可能会代表一个子域,或者一个比较关键的路径前缀,比如一个网站有新闻、博客或者论坛,那就会有不同的 sitechunk。这样允许 Google 针对同一个域名下的不同的频道、标签做不同的分析。所以下面讨论到所有的站点信号,都应该理解成是 sitechunk 信号。

QualityNsrNsrData 中,有以下这些特征:

  • smallPersonalSite:为个人博客小站提权的分数。我一直觉得谷歌对博客网站很友好,果然在站点特征体系中有专门的提权打分。
  • siteAutopilotScore:如果一个网站的内容都是自动生成的,它会被称为是一个 Autopilot Website。这个分数是描述这个站点下所有页面自动生成方面评分的一个汇总值。
  • isVideoFocusedSite:如果站点有超过一半的页面是视频播放页面,而且它又不是一些知名的视频网站,那么这个特征就是 true。
  • ugcScore:与上面分数相似,可能是这个站点每个页面是否为 UGC 内容的评分的一个汇总值。
  • videoScore:与 ugcScore 相似,可能是这个站点每个页面是否为视频播放页的评分的汇总。
  • shoppingScore:与 ugcScore 相似,可能是这个站点每个页面是否为商品购买页的评分的汇总。
  • localityScore: 与 ugcScore 相似,可能是这个站点每个页面是否为 LBS 服务页的评分的汇总,不过这里提到了一个叫做 LocalAuthority 的模块/策略,希望以后能弄懂它。
  • articleScoreV2: 与 ugcScore 相似,可能是这个站点每个页面是否为文章页的评分的汇总。
  • healthScore: 与 ugcScore 相似,可能是这个站点每个页面是否为医疗健康页的评分的汇总。
  • ymylNewsV2Score:无注释,YMYL 是 Your Money Your Life 的缩写,这里可能是指这个站点每个页面是否为敏感(健康、金融相关的)新闻页的评分的汇总。
  • clutterScore: 判断站点是否加载了很多乱七八糟的内容,比如加载了很多不同来源的广告之类。
  • clutterScores: 带版本的 clutterScore。
  • racterScores: 站点级别 AGC 分类打分;
  • titlematchScore:大概是这个网站每个网页的标题,能匹配上多少 Google Query 的一个综合评分;
  • siteQualityStddevs: 站点质量标准差,从名字判断,可能来自于站点所有网页的站点质量得分的统计。从这些方差指标可以看出,Google 很在乎站点内容的一致性,可能对页面质量参差不齐的站点有打压。
  • chromeInTotal:站点级别的 Chrome 访问量;
  • impressions:站点在 Google 搜索结果中的展现次数;
  • chardEncoded: 有人说 chard 代表 CHrome AveRage Duration,站点平均停留时间,我本来猜测可能是 CHrome Average Returning Days,或者 CHrome Average Retention Data。核心就是我觉得这是一个留存指标,留存比时长更能体现网站的受欢迎程度。但注释中又说它是 site quality predictor based on content,所以也许我的理解是错的,也许 c 是 Content?但是 hard 是什么,我实在猜不出来了。
  • chardVariance:站点(首页) chard 的方差。
  • chardScoreEncoded: 站点中所有页面的 chard 得分;
  • chardScoreVariance:站点所有页面 chard 得分的方差。
  • nsrVariance: 站点首页 nsr 与站点所有页面质量均值的差;
  • siteQualityStddev: 站点所有页面质量的方差,与 nsrVariance 不同,它衡量的是页面之间的方差;
  • tofu: 与 chard 一样,都是基于 content 算出来的一个得分。tofu 是豆腐块的意思,在网页里可能代表了页面内有多少个豆腐块区域,或者有多少个豆腐块广告。
  • pnavClicks:PNAV 大概是指 Primary Navigation,即站点的主要导航链接。这个值是对主要导航链接点击数的一个分母,可能在某个地方记录了这个站点每个导航链接的点击数,这样就能算出来哪些导航更受欢迎,也许是用在搜索结果页中展示站点的关键导航上;
  • pnav: 一个分位值,可能是主要导航链接占页面链接数比例?
  • vlq: 视频低质量模型的打分,猜测 LQ 代表 Low Quality。
  • vlqNsr: 针对低质量视频站点设计的一个额外的 nsr 打分,有可能是为了避免这些站点 nsr 得分过低,导致一些用户需求不满足(例如某类视频)。
  • siteLinkOut: 这个站点所有外链的平均得分;
  • siteLinkIn: 这个站点所有内链(反向链入的页面)的平均得分;
  • siteChunkSource: sitechunk 来源,可能是记录怎么分的 chunk;
  • spambrainLavcScores:这个没有注释,看起来是 Google 有一个 spambrain,会给站点一个 Lavc 分数,应该表示网站是否有 spam 行为的打分;
  • sitePr:站点的 PageRank。
  • nsr: 也许是最原始的 Normalized Site Rank,用一个分值代表站点质量。
  • versionedData: 实验版本的 nsr 值,当算法升级后 nsr 计算逻辑与以前不同时,先拿它用来做实验;
  • priorAdjustedNsr: 先验调整 nsr,用于判断当前站点的 nsr 在它所属的 slice 里比平均 nsr 高还是低;
  • ketoVersionedData: 带版本的 keto 数据,包括站点得分和站点得分在所有站点中的分位值。keto 可能代表了一个策略,含义未知。
  • nsrOverrideBid: 用来干预 nsr,当它的值提供并且大于 0.001 时,直接覆盖掉 nsr。也就是说可以通过人工干预调高或者调低某个站点的 nsr。
  • nsrEpoch: nsr 最早的获取时间;
  • siteChunk: nsr 对应的主 sitechunk,即分析出来的 sitechunk 对应的文档 URL;但文档中提到在一些稀有情况下,它可能基于网页中的一个标记。我猜测像一些 Single Page Application,URL 全部使用 # 页内标记,这种情况下只能使用页内标记来标记 sitechunk。
  • secondarySiteChunk: nsr 对应的二级 sitechunk,如果存在的话,划分比 sitechunk 粒度更细。
  • i18nBucket: 属于哪(几)种语言,这是一个 int 值,也许会是一个 bitmap,可以把站点放入多个语言桶中。
  • language: 站点的语言,暂不清楚与 i18nBucket 的差异,因为它也是一个 int 值。
  • isCovidLocalAuthority: 是否为 Covid 本地官方网站,也许是在疫情期间对官方网站消息的提权;
  • isElectionAuthority: 是否为(美国)选举相关的官方网站;
  • directFrac: 无注释,我猜测是 Chrome 输入 URL 直接访问的 PV 占所有访问量的占比。
  • site2vecEmbedding: 看起来像是将上面的每个站点 nsr 特征,综合起来表达成了一个稀疏的 embedding,可能是 one-hot 编码那种,也可能是稀疏模型编码;
  • site2vecEmbeddingEncoded:这里是一个压缩版本的 embedding,主要用于 SuperRoot。
  • nsrdataFromFallbackPatternKey: 如果为真,代表以上的 nsr 特征都来自于其它站点;
  • url:站点的 URL;
  • host: 站点的域名或者主机名;
  • clusterId:站点所属站群的 ID,被一个叫做 Tundra 的生态项目所使用,这个项目在文档中也出现过多次,希望后面能弄清楚它的含义。站群一般是指页面互相之间有链接的一批站点,会被用来做 SEO 提升 pagerank,看起来 Google 对这种行为是有识别的。
  • clusterUplift: 与上面提到的 Tundra 项目有关,主要看站群是不是小站,是不是本地站,可能是用于站点的提、降权;
  • metadata: 记录了一些在不同系统里查找 nsr 数据的 key,或者一些数据的生成时间。

Google Search 淘气三千问:Q1~Q5

前言

两个多月前(2024年5月27日),Google 的一份名为 GoogleApi.ContentWarehouse 的 API 文档受到 SEO 圈的关注,由于这份文档的内容和 Google Search 副总裁 Pandu Nayak 在 2023 年美国司法部(DOJ)起诉 Google 的案件中的证词和 Google 的一些专利高度一致,因此其真实性被广泛认可。

后续有媒体称 Google 发言人回应了文档泄露的问题(没有承认、也没有否认):

A Google spokesperson sent us the following statement:

“We would caution against making inaccurate assumptions about Search based on out-of-context, outdated, or incomplete information. We’ve shared extensive information about how Search works and the types of factors that our systems weigh, while also working to protect the integrity of our results from manipulation.”

此前大部分讨论仅限于猜测 API 文档中的各种信号在排名算法中的作用,以及对谷歌是否在排名算法上欺骗了大家。很少人意识到,这篇 2500 页的文档可以作为以往 Google 公开论文的补充,一本叫做《如何构建一个世界级(成功的)搜索引擎》的武功秘籍撕下来的几页。

而偏偏我对这本武功秘籍非常好奇,试图在这几页上再加一些批注,就有了 《Google Search 淘气三千问》这个系列。这个系列会有几篇文章,我不知道,主要看我能想到多少问题。

为什么叫“淘气三千问”这个名字?可以把它看成是一种传承吧,懂的都懂,不懂的也不影响阅读。

为避免误导读者,这个系列所有的回答里,来自公开信息的我都会标注来源,没有标注来源的,你可以认为是 inaccurate assumptions about Search based on out-of-context, outdated, or incomplete information。当未来有了更新的信息,我有可能回到博客来更新这些 assumptions(公众号文章无法更新)。

如果你有新信息可以给我,或者纠正文中的错误,欢迎评论或者到公众号“边际效应”私信,谢谢!

Q1: Google 的索引分了几层(Tier)?依据什么?

Google 在 2012 年的论文《Indexing the World Wide Web: The Journey So Far 》中提到产业实践中大规模索引都是会分成多个桶(tier),一般按照更新频率来分:

The way we have described search indices so far makes a huge assumption: there will be a single unified index of the entire web. If this assumption was to be held, every single time we re-crawled and re-indexed a small set of fast-changing pages, we would have to re-compress every posting list for the web and push out a new web index. Re-compressing the entire index is not only time consuming, it is downright wasteful. Why can we not have multiple indices -- bucketed by rate of refreshing? We can and that is what is standard industry practice. Three commonly used buckets are:

1. The large, rarely-refreshing pages index
2. The small, ever-refreshing pages index
3. The dynamic real-time/news pages index

...

Another feature that can be built into such a multi-tiered index structure is a waterfall approach. Pages discovered in one tier can be passed down to the next tier over time.

在 Google ContentWarehouse API 里有这样一段 :

GoogleApi.ContentWarehouse.V1.Model.PerDocData

* scaledSelectionTierRank (type: integer(), default: nil) - Selection tier rank is a language normalized score ranging from 0-32767 over the serving tier (Base, Zeppelins, Landfills) for this document. This is converted back to fractional position within the index tier by scaled_selection_tier_rank/32767.

可以看到,Google 仍然是把索引分了 3 层,现在我们有了它们的名字,分别是:Base(基础)、Zeppelins(飞艇) 和 Landfills(垃圾填埋场)。在每一层之内,scaledSelectionTierRank 这一归一化分数决定了它所在位置的分位数。分位数最大值是 32767,猜测也许是 Google 在索引存储里只给它留了 15 bits(2^15=32768)。

但从索引分层的名字来看,这三层并不(全)是按照时效性分的,至少第三层,看着是按照文档质量分的。因为你把文档放到“垃圾填埋场”中,大概率因为它的质量较差而不是不再更新。那么 scaledSelectionTierRank 也许就代表了层内的文档质量等级。

网友 avanua 对这三层的命名提供了一个解读,我觉得非常合理,因为我一直困惑第二层为什么叫做 Zeppelins:

我觉得 Tier 命名和质量无关,可能只是用来描绘更新频率:
​Zeppelins 在气流中起起伏伏
Base
​Landfills 几乎不会再翻动,上下层叠关系是固定的

Q2: Tier 内的 scaledSelectionTierRank 有什么作用?

在 《Indexing the World Wide Web: The Journey So Far》中提到,在倒排拉链中最好按照文档实际的影响力对文档列表进行排序。如果仅仅是这样,那么只需要知道文档 0 比文档 10000 更重要即可,那么额外记录一个打分的目的,其实是可以让这个分数参与排序过程。文档在某个 Query 下的得分,是文档影响力得分乘以文档在 Query-term 下的权重。

Since it made sense to order the posting lists by decreasing term frequency, it makes even more sense to order them by their actual impact. Then all that remains is to multiply each posting value by the respective query term weight, and then rank the documents. Storing pre-computed floating-point document scores is not a good idea, however, since they cannot be compressed as well as integers. Also, unlike repeated frequencies, we can no longer cluster exact scores together. In order to retain compression, the impact scores are quantized instead, storing one of a small number of distinct values in the index.

从上文中有理由认为,scaledSelectionTierRank 就是文中提到的量化以后的文档影响力得分,量化就是将其归一化到 32768 个分档之中。

Q3: Google 搜索系统主要分成几个部分?

通过 API 和其它公开文档,目前我能够分析出来的搜索系统组成部分有以下这些。随着阅读的深入,可能还会有新的部分加进来。

爬虫系统:Trawler

在 Google ContentWarehouse API 中有一系列 API 以 Trawler 为前缀,并且从上下文中看出来 Trawler 是一个实体系统并且有一个研发团队。

GoogleApi.ContentWarehouse.V1.Model.TrawlerCrawlTimes
GoogleApi.ContentWarehouse.V1.Model.TrawlerFetchReplyData
GoogleApi.ContentWarehouse.V1.Model.TrawlerHostBucketData

* TotalCapacityQps (type: number(), default: nil) - The following four fields attempt to make things simpler for clients to estimate available capacity. They are not populated yet as of 2013/08/21. Even after they are populated, they may change. So talk to trawler-dev@ before you use the fields. Total qps for this hostid

去重系统:WebMirror

在 Google ContentWarehouse API 里有这样一段:

GoogleApi.ContentWarehouse.V1.Model.CompositeDocAlternateName

Alternate names are some urls that we would like to associate with documents in addition to canonicals. Sometimes we may want to serve these alternatenames instead of canonicals. Alternames in CompositeDoc should come from WebMirror pipeline.

每个 CompositeDoc 都有一些替代的 URL,这些 URL 来自 WebMirror 流水线,那么 WebMirror 应该是识别重复文档的一套系统。

离线索引构建系统:Segindexer + Alexandria

在 Google ContentWarehouse API 里有这样一段:

GoogleApi.ContentWarehouse.V1.Model.AnchorsAnchor

* sourceType (type: integer(), default: nil) - ... In the docjoins built by the indexing pipeline (Alexandria), ...

所以 Alexandria 应该是建库系统。而 Segindexer 和 Alexandria 曾经并行出现过:

GoogleApi.ContentWarehouse.V1.Model.ClassifierPornClassifierData

* imageBasedDetectionDone (type: boolean(), default: nil) - Records whether the image linker is run already. This is only used for Alexandria but NOT for Segindexer.

考虑到关键的表示原始文档内容的 compositedoc.proto 在 Segindexer 目录下:

GoogleApi.ContentWarehouse.V1.Model.NlpSaftDocument

* bylineDate (type: String.t, default: nil) - Document's byline date, if available: this is the date that will be shown in the snippets in web search results. It is stored as the number of seconds since epoch. See segindexer/compositedoc.proto

从名字和上述信息有理由怀疑 Segindexer 是在 Alexandria 之前,决定了索引分层,或者分 vertical 的一个分类模块。

在线索引服务系统:Mustang 和 TeraGoogle

在 《Indexing the World Wide Web: The Journey So Far》中我们知道,TeraGoogle 是 Google 在 2005 年实现的一套 large disk-based index 服务系统。而在 Google ContentWarehouse API 里有这样一段:

GoogleApi.ContentWarehouse.V1.Model.CompressedQualitySignals

A message containing per doc signals that are compressed and included in Mustang and TeraGoogle.

这里将 Mustang 和 TeraGoogle 并列,有理由认为 Mustang 是 2005 年之后 Google 开发的一套替代或者部分替代 TeraGoogle 的在线索引服务系统。

实时索引服务系统:Hivemind(muppet)

Realtime Boost 这篇文档中有一张图,揭示了一个新网页进入索引的过程,其中实时索引的系统叫做 Hivemind。但 Hivemind 应该是个集群名,实际的索引系统方案可能是叫做 muppet。因为在 RealtimeBoost Events - DesignDoc 这篇文档中也提到了一个用 muppet 支持的系统,叫做 ModelT,这是一个索引实时事件的索引系统。

实时索引的建库系统可能与非实时索引不同,叫做 Union。

查询汇聚系统:SuperRoot

在 Google ContentWarehouse API 中多次出现 SuperRoot 这一模块,而在 Jeff Dean 2009 年 WSDM 的 《Challenges in Building Large-Scale Information Retrieval Systems》 分享第 64 页,SuperRoot 被描述为聚合 Web、Images、Local、News、Video、Blogs 和 Books 所有检索子系统的汇聚模块,这个定位也许没有变。

Query 改写模块:QRewrite

RealtimeBoost Events - DesignDoc 这篇文档中说:

Currently in production RealtimeBoostServlet runs in QRewrite detects spikes on news documents published that match the SQuery (including syns). It does so by issuing one or two RPCs to Realtime-Hivemind.

The RealtimeBoostResponse containing the Spike is sent down to Superroot in the QRewrite response and it is currently used by a few Search features (such as TopStories) to trigger faster and rank fresher documents for queries that are spiking for a given news event.

所以 QRewrite 是会被 SuperRoot 调用的一个 Query 改写模块,它会将用户实际发起的搜索 Query 改写成 SQuery,S 可能是代表 Superroot。而到真正发起检索时,会进一步转成检索 Query,比如发给 muppet 的 Query 叫做 MQuery。

摘要模块:SnippetBrain

在 Google ContentWarehouse API 里有这样一段:

GoogleApi.ContentWarehouse.V1.Model.MustangReposWwwSnippetsSnippetsRanklabFeatures

* displaySnippet (type: GoogleApi.ContentWarehouse.V1.Model.QualityPreviewRanklabSnippet.t, default: nil) - Snippet features for the final chosen snippet. This field is firstly populated by Muppet, and then overwriten by Superroot if SnippetBrain is triggered.
  • 看起来 SnippetBrain 是一个可选的摘要生成模块。

入口服务:GWS

Google Web Server,这个大家都知道,还有 Wikipedia 词条

Q4: TeraGoogle 是怎样一套系统?

根据论文《Indexing the World Wide Web: The Journey So Far》和专利《US7536408B2: Phrase-based indexing in an information retrieval system》,TeraGoogle 应该有以下几个属性:

  • Disk-based Index:索引存储在磁盘上,在需要的时候读入到内存中,而且往往不需要全部读入,针对重要的文档有一些优化;
  • Phrase-based Indexing:构建索引的时候不仅仅有 term 索引,还会建设多 term 的短语索引,这样索引库里会有更多的倒排链;
  • Document-Partitioned Index:将索引分 Shard 的时候,按照文档进行分片,即同一批文档的所有拉链放在同一个 Shard 上,这样每个 Shard 上有所有的拉链,查询在一个节点内即可完成。在论文中只对比了 Document-Partitioned 和 Term-Partitioned 二者的差异,在 Jeff Dean 2009 年 WSDM 的 《Challenges in Building Large-Scale Information Retrieval Systems》 分享第 17 页确认了 Google 的选择。

Q5: Google 的文档是什么概念?

从 Google ContentWarehouse API 里:

GoogleApi.ContentWarehouse.V1.Model.CompositeDoc
Protocol record used for collecting together all information about a document. 

可以看到 CompositeDoc 是在系统里非常重要的概念,它定义了一个文档的所有信息。在它的所有字段中我们发现,url 又是一个可选的字段,这也就是说,文档并不一定需要是一个网页。像 localinfo,看起来就像是一个 POI 信息。也就是说在 Google 的系统里,不一定只有网页索引,可能每个 POI 点、图片、商品也是一种文档,所以它使用 CompositeDoc (复合文档) 而不是 WebPage 作为整个系统里对文档的刻画。

Google App API Protocol - Text Search

Google 在移动平台(Android 和 iOS)上提供了独立的 Search App,但它不仅仅是用一个移动浏览器封装了 Google Web Search,而是做了很多移动应用相关的改进。这个系列文章,通过抓包对 Android Google App 与 Server 间通讯协议进行简单分析,管中窥豹,以见一斑。

  1. Google App API Protocol - Text Search
  2. Google App API Protocol - Voice Search
  3. Google App API Protocol - Search History

HTTPS 抓包分析

Google Service 已经全面普及了 HTTPS 接入,所以想探索 Google 的通讯协议,首先必备的是 HTTPS 抓包能力。所谓的 HTTPS 抓包,实质上是通过代理服务器实现对测试手机上 HTTPS 连接的中间人攻击,所以必须在测试手机上安装代理服务器的 CA 证书,才能保证测试手机相信 HTTPS 连接是安全的。

有很多测试用代理服务器支持 HTTPS 抓包,例如 FiddlerCharles,HTTPS 配置具体可以参见它们的官方文档:Configure Fiddler to Decrypt HTTPS TrafficSSL CERTIFICATES.

文本 IS 请求

Google App 上的文本搜索请求,并不一定以用户按下搜索按钮才开始,而是在输入过程中就可能发生,类似于 Instant Search 即时搜索。这一切发生在输入文本过程中的 "/s?" 请求内。

Google 在接口上,已经将搜索推荐和即时搜索合二为一,通过发起对 "https://www.google.com[.hk]/s?" 的 GET 请求,根据用户已经输入的短语,获取搜索推荐词。如果 Google 认为用户已经完成输入,它会在这个请求的应答消息中直接返回搜索结果。

以小米手机上安装的 Google App 为例,当用户输入『上海』这个词时,GET 请求的参数,主要有以下这些:

noj	1
q	上海
tch	6
ar	0
br	0
client	ms-android-xiaomi
hl	zh-CN
oe	utf-8
safe	images
gcc	cn
ctzn	Asia/Shanghai
ctf	1
v	5.9.33.19.arm
biw	360
bih	615
padt	200
padb	640
ntyp	1
ram_mb	870
qsd	3670
qsubts	1458723210129
wf	pp1
action	devloc
pf	i
sclient	qsb-android-asbl
cp	2
psi	bLxzwaswPFc.1458723200693.3
ech	2
gl	us
sla	1

请求的 HTTP Header,主要有以下这些:

Connection	keep-alive
Cache-Control	no-cache, no-store
Date	Wed, 23 Mar 2016 08:53:26 GMT
X-Client-Instance-Id	c6aef75ce70631e9518ae8e7011bb3d7957b24d59b62f...
Cookie	******
X-Geo	w CAEQDKIBBTk6MTox
X-Client-Opt-In-Context	H4sIAAAAAAAAAONi4WAWYBD4DwOMUiwcj...
X-Client-Discourse-Context	H4sIAAAAAAAAAB1PS07DMBSUehe...
X-Client-Data	CM72hgQIjfeGBAiP94YECKj4hgQIy...
User-Agent	Mozilla/5.0 (Linux; Android 4.4.4; HM 1S Build/KTU84P)
 AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/33.0.0.0 Mobile
 Safari/537.36 GSA/5.9.33.19.arm
Accept-Encoding	gzip, deflate, sdch

文本 IS 应答消息解码

文本 IS 应答消息的 HTTP Header 如下所示:

HTTP/1.1 200 OK
Content-Type	application/x-protobuffer
Date	Wed, 23 Mar 2016 08:56:47 GMT
Expires	-1
Cache-Control	no-store
Content-Disposition	attachment; filename="f.txt"
Content-Encoding	gzip
Server	gws
X-XSS-Protection	1; mode=block
X-Frame-Options	SAMEORIGIN
Alternate-Protocol	443:quic,p=1
Alt-Svc	quic=":443"; ma=2592000; v="31,30,29,28,27,26,25"
Transfer-Encoding	chunked

这里最值得关注的,是 Content-Type。application/x-protobuffer 不是一个常见的 Media Type,起初我以为它就是简单的 protobuffer message 序列化二进制内容,搜索到的一些信息也是这样说的,但用 protobuf 对其 Decode,并不能正确解析消息体。后来我还是在 Charles 的这篇文档中找到了思路,其实 application/x-protobuffer 在实现时区分单个消息和多个消息的格式(但 Content-Type 里并不显式说明)。多个消息,即 Dilimited List,的封包协议是这样的:

(Varint of Msg Len)(Msg)(Varint of Msg Len)(Msg)...(Varint of Msg Len)(Msg)EOF

解包时也很简单,Protobuf 的 Java 库提供了 Message.Builder.mergeDelimitedFrom(...) 来直接从 InputStream 里循环读取多个 Message 的封包数据。

但这时候我们还不知道应答消息里的 protobuf Message 格式,无法构建 Message 的 Builder。这时候有个简单的办法去逐步推导,也就是新建一个 Empty Message,如下所示:

$ more textsearch.proto
message Empty {}

用这个 Empty Message 构建 Builder,对 IS 的应答消息进行 Decode,将 Decode 结果打印出来时会发现所有的字段都是无名字段。然后根据对 protobuf wire data 的理解,逐步推导它的 Message 格式,尽最大的努力去猜测各个字段的作用,最终推导出来 IS 应答消息的 .proto 可能是这样的:

$ more textsearch.proto
package com.google.search.app;

message SearchResponse {
    required string search_id = 1;
    optional uint32 msg_type = 4;
    optional SearchResultPart sug = 100;
    optional SearchResultPart result = 101;
    optional Empty SR102 = 102;
}

message SearchResultPart {
    optional uint32 fin_stream = 1;
    optional string text_data = 2;
    optional string html_data = 7;
    optional string encoding = 8;
}

message Empty {
}

基于这个 .proto,对 Query 『上海天气』 IS 的应答消息进行 Decode,最终的结果摘要如下:

$ more s.txt
#### Proto: Textsearch.SearchResponse with Size=758
search_id: "iVnyVrChHsTAjwPdh4PwBA"
msg_type: 97000
sug {
  fin_stream: 1
  text_data: "[\"上海天气\",[[\"上海天气\",35,[39,70],......}]"
}

#### Proto: Textsearch.SearchResponse with Size=10561
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
msg_type: 97000
result {
  fin_stream: 0
  text_data: "上海天气"
  html_data: "<!doctype html><html itemscope=\"\" ......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
#### Proto: Textsearch.SearchResponse with Size=16951
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: "<style data-jiis=\"cc\" id=\"gstyle\">......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
#### Proto: Textsearch.SearchResponse with Size=1511
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: "<title>上海天气 - Google 搜索</title></head><body ......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
#### Proto: Textsearch.SearchResponse with Size=68
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: ""
  encoding: "text/html; charset=UTF-8"
  3: 0
  9: 1
}
SR102 {
  1: 1
  4: ""
}
#### Proto: Textsearch.SearchResponse with Size=104557
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: "<div data-jiis=\"cc\" id=\"doc-info\">......</script>"
  encoding: "text/html; charset=UTF-8"
  4: 8
  4: 10
  4: 6
  4: 13
  4: 12
  4: 2
  4: 21
  4: 20
  9: 1
  10: 1
}
#### Proto: Textsearch.SearchResponse with Size=2198
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: "<script>google.y.first.push(function()......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
#### Proto: Textsearch.SearchResponse with Size=2146
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: "<script>......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
#### Proto: Textsearch.SearchResponse with Size=7261
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: "<script>......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
#### Proto: Textsearch.SearchResponse with Size=1202
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 1
  html_data: " <div id=\"main-loading-icon\" ......</div></body></html>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}

观察到 sug_data 看似是 JSON 格式的数据,专门对 sug_data 进行 JSON Decode,得到以下结果:

## JSONArray: sug_data ##
[
  "上海天气",
  [
    [
      "上海天气",
      35,
      [
        39,
        70
      ],
      {
        "ansc": "1458723207451",
        "ansb": "2338",
        "du": "/complete/deleteitems?client=qsb-android-asbl&delq=上海天气
               &deltok=AKtL3uTL0hK_EMlKCgutzvQvzXfh2VRAgg",
        "ansa": {"l": [
          {"il": {"t": [{
            "tt": 13,
            "t": "上海天气"
          }]}},
          {"il": {
            "at": {
              "tt": 12,
              "t": "周三"
            },
            "t": [
              {
                "tt": 1,
                "t": "54"
              },
              {
                "tt": 3,
                "t": "°F"
              }
            ],
            "i": {
              "d": "//ssl.gstatic.com/onebox/weather/128/partly_cloudy.png",
              "t": 3
            }
          }}
        ]},
        "zc": 602
      }
    ],
    [
      "上海天气<b>预报<\/b>",
      0,
      [],
      {"zc": 601}
    ],
    [
      "上海天气<b>预报10天<\/b>",
      0,
      [],
      {"zc": 600}
    ],
    [
      "上海天气<b>预报15天<\/b>",
      0,
      [],
      {"zc": 551}
    ]
  ],
  {
    "q": "W9D2ISTC-TXK4QauyTt2SFqJzvo",
    "n": 0
  }
]

非 IS 搜索请求和搜索应答解码

当 IS 应答消息里有搜索结果时,点击搜索按钮不会再发起一次搜索。但如果 IS 应答只有 SUG,没有搜索结果,Google App 就会发起一次非 IS 的正常搜索请求。这次请求除了请求的 URL path 从 "/s" 变成 "/search" 以外,主要的 GET 参数保持一致,会有部分附加参数的不同。以『上海天气』(有 IS 结果)和『上海天气好不好呢』(无 IS 结果)为例,GET 参数有以下区别:

-qsd	3670
-pf	i
-sclient	qsb-android-asbl
-cp	4
-psi	bLxzwaswPFc.1458723200693.3
-ech	3
-gl	us
-sla	1
+gs_lp	EhBxc2ItYW5kcm9pZC1h...
+source	and/assist
+entrypoint	android-assistant-query-entry

而非 IS 搜索应答和 IS 应答对比,区别主要在于非 IS 搜索应答消息中,没有搜索词 SUG Message 包。

协议分析和启发

请求消息协议

搜索请求是通过 GET 协议实现的,所以请求主要分为两部分:HTTP 头和 GET 参数。从请求上来看,Google 对 GET 参数的使用是非常节省的,很多字段都是极其精简的缩写。但它倒是在 HTTP 头里放了很多比较大的数据字段,从 Header 名来猜测,应该是跟设备、登录用户相关的一些加密字段。

因为搜索请求是 App 发出的,所以理论上 GET 请求和 POST 请求的实现难度是差不多的,POST 的时候可以进行数据压缩,Header 倒是不能压缩(HTTP 1.x)。那为什么 Google 反而选择把这么长的数据放在 HTTP Header 里呢?我的猜测是为了充分利用 HTTP/2 的特性。在 HTTP/2 里有个特性,叫做 Header Compression,在多次请求时,同一个 Header 原文仅需要压缩传输一次即可。但由于现在还没有 HTTP/2 的抓包工具,所以还无法判断 Google App 是已经用上了这个特性,还是为未来的使用做好准备。不过这至少给了我们一个启发,为了充分利用底层协议的特性,应用层约定可能也需要一些适配工作。

应答消息协议

Google App 的搜索结果,并没有像普通网站服务一样,直接用标准的 HTML 协议返回一个 Web Page。而是将渲染好的 Web Page 分段放到应答消息中,由 App 端提取、拼接成最终的搜索结果页。猜测有以下几点考虑:

  1. 便于与 SUG 服务集成。很多搜索框都提供 Sug 功能,但 Google 为了让用户感觉更快,在输入过程中不仅有 Sug,还会直接显示搜索结果,桌面版上叫做『即时搜索』。移动版 App 的做法跟桌面版类似,但实现上有不同的地方。移动 App 的输入框不是 HTML 的 <input> 标签,而是一个系统原生的输入框,所以无法依赖 Javascript 去响应事件,刷新结果页。而且网络请求是 App 发起的,为了充分利用网络连接,将搜索结果集成到 SUG 结果里也是顺理成章的事情。不过这还意味着在 App 上,不能仅返回数据通过 Ajax 技术无缝刷新结果页,必须得在消息里返回整个搜索结果页。
  2. 减少解码内存使用,改善性能。如果将整个搜索结果页放到一个 Protobuf Message 中,客户端为了解码这个消息,需要申请很大一块内存。而在移动设备上,内存是很 critical 的资源,尤其是在 Android 设备上,使用大块内存会导致频繁的 GC,性能很差。
  3. 模拟 Chunked 编码。Web 服务器可以通过 HTTP 1.1 引入的 Chunked transfer encoding 将网页分块传输给浏览器,浏览器无需等待网页传输结束,就能够开始页面渲染。当网页通过 Protobuf Message 传输时,无法利用浏览器的 chunked 处理技术,只好用分拆为多个 Message 的方式模拟 chunked 模式。虽然 Android 原生的 Webview 并没有支持 chunked 的 LoadData 接口,相信 Google 自己的 App 实现一个类似功能并不困难。

但 Google 这种直接下发网页数据的做法,也存在一个问题,就是没有合法的网页 URL。合法的网页 URL,有以下几个潜在的作用,Google App 做了一些额外的工作来处理:

  1. 刷新页面。Google App 没有提供页面刷新功能;
  2. 前进后退。Google App 没有提供前进后退功能,而是通过 Search History 来满足后退功能。
  3. 浏览历史。Google App 没有提供浏览历史,只提供了搜索历史。
  4. 页面分享。Google App 没有提供页面分享功能。

Android 版本的 Google App 连搜索结果都需要用第三方浏览器打开,结合上述功能处理,可以发现 Android 版本 Google App 只是想做一个精悍的搜索应用,无意于把自己变成一个完善的浏览器。但 iOS 版本 Google App 对上述问题的处理略有不同,iOS 版本内置了一个浏览器,搜索结果可以在 App 内打开。但主要的搜索结果页,仍然是采用类似于 Android 的方式处理。也就是说,iOS 版本的浏览器,可能仅仅是一个 UIWebview。

搜索引擎和我的博客

今天想起了两件比较有意思的搜索引擎的事情:

1. Google PageRank 没有停止更新。

我今天忽然发现我的博客 http://blog.solrex.cn 的 PageRank 变成了 3,而去年 PageRank 在传说中停止更新半年后恢复时我的 PageRank 才是 2。这说明 Google 没有再次停止更新 PageRank。PageRank 是 Google 对网页重要性的意见,虽然一个数字代表不了什么,但是看着自己的网站被 Google 欣赏总是开心的事情 :-)。

2. 百度服务器资源紧缺是真的。

由于域名服务器问题,我的域名 solrex.cn 在 07 年末到 08 年初有 6 天时间无法解析 IP 地址。我发现问题后曾经尝试过在 Google 和百度搜索和我相关的关键字,Google 给出的结果没有什么差别,而百度却没有一条来自 solrex.cn 相关的记录。但是在 IP 解析恢复一天后,再百度搜索“solrex”,blog.solrex.cn 就成为搜索记录的第一条。这说明:一,百度服务器资源确实短缺,否则它没有必要删除一个仅仅6天不能访问的网站,不然要百度快照干什么?二,百度抓取蜘蛛反应很快,才恢复一天的网站就已经被重新收录了。