如何高效穷尽爬取带单查询结果限制的Web目录全量内容
在单查询返回结果数仅占目录总条目数极小比例的限制下,如何高效构造查询序列以完整获取姓名类目录的全部结果?
我的工作任务是爬取多所高校的人员目录全量信息,这类目录收录了全校教职工的相关信息,包括姓名、邮箱地址、职称、所属院系等需要采集的字段。对于大多数目录,核心目标是获取目录中每位成员对应的详情页URL,后续即可单独采集每个人员的详细信息,因此只要拿到目录内全部人员的姓名列表就足以满足需求。
部分目录不直接展示全部结果,必须提交搜索查询才能返回对应内容;这类目录通常支持按一个或多个字段检索,包括名、姓、院系等维度。但这类搜索接口普遍设置了单查询最大返回结果数限制,导致无法直接通过搜索A、B、C等单字母前缀拿到全量结果。
查询的匹配规则
所有目录的搜索查询均不区分大小写,不同目录对查询语句的匹配逻辑存在差异,目前观测到三类匹配规则:
假设存在如下测试目录:["Abby", "Abraham", "Alb", "Babbage"]
- 1. 隐式后缀通配符:返回所有以查询字符串为开头的结果
该规则下搜索ab会返回Abby和Abraham,不会返回Babbage。 - 2. 隐式前后双通配符:返回所有包含查询字符串的结果
该规则下搜索ab会返回Abby、Abraham和Babbage。 - 3. 模糊匹配:返回所有包含查询字符串或与查询字符串拼写相近的结果
该规则下搜索ab会返回目录内全部4个姓名。
基于上述三类匹配规则,我设计了一套默认按隐式后缀通配符逻辑构造查询的算法。选择该逻辑的原因是:当目录实际采用隐式双通配符或模糊匹配规则时,同个查询的返回结果是后缀通配符逻辑下结果的超集,因此这套算法可适配所有三类匹配场景。
该方案存在一个潜在缺陷:在双通配符或模糊匹配场景下,单查询返回的结果数量会大幅增加,在相同的单查询结果上限约束下,需要发起更多查询才能覆盖完整目录。
算法流程说明
算法的执行步骤如下:
- 将姓氏查询的初始值设置为
a。 - 发起姓氏查询。
- 2a. 若返回结果数未超出上限,则将结果存入结果集,将姓氏查询字符串的最后一位字符递增(例如从
a变为b,或从apple变为applf)后回到步骤2(该递增过程支持进位逻辑,例如azzz递增后为b);若递增过程溢出,则代表搜索完成,直接跳转至步骤4;若返回结果数超出上限,则进入步骤2b。 - 2b. 将名字查询的初始值设置为
a。 - 2c. 同时传入名字查询参数和姓氏查询参数发起检索。
- 2d. 若返回结果数未超出上限,则将结果存入结果集,将名字查询字符串的最后一位字符递增后回到步骤2c;若名字查询的递增过程溢出(例如名字查询值为
z时返回结果仍未超上限),则清空名字查询值,进入步骤3;若返回结果数超出上限,则在名字查询字符串末尾追加a后回到步骤2c。
- 2a. 若返回结果数未超出上限,则将结果存入结果集,将姓氏查询字符串的最后一位字符递增(例如从
- 在姓氏查询字符串末尾追加
a后回到步骤2。 - 返回最终收集的结果集。
Python实现代码
上述算法的Python伪代码实现如下,代码中包含make_query()、increment()、append_a()等辅助函数:
import string alphabet = string.ascii_lowercase names = get_random_names(n=10000) results_limit = 25 def make_query(first="", last=""): print("Querying for the following:") print("first:", first) print("last:", last) results = set() for n in names: f, l = n if f.lower().startswith(first) and l.lower().startswith(last): results.add(n) if len(results) > results_limit: print("Too many results") print() return set(), True else: print("Success! This gave " + str(len(results)) + " results") print() return results, False def increment(q): ql = [chars.index(c) for c in q] while ql[-1] == len(chars) - 1: del ql[-1] if len(ql) == 0: return ql, True ql[-1] += 1 return "".join([chars[i] for i in ql]), False def append_a(q): ql = [chars.index(c) for c in q] ql.append(0) return "".join([chars[i] for i in ql]) def search_directory(field="last", fixed_last=None): all_results = set() query = "a" num_queries = 0 while True: if field == "last": query_results, over_limit = make_query(last=query) num_queries += 1 elif field == "first": query_results, over_limit = make_query(first=query, last=fixed_last) num_queries += 1 if not over_limit: all_results = all_results.union(query_results) query, is_finished = increment(query) if is_finished: return all_results, num_queries continue elif over_limit and field == "last": first_name_results, first_num_queries = search_directory(field="first", fixed_last=query) num_queries += first_num_queries all_results = all_results.union(first_name_results) query = append_a(query) results, num_queries = search_directory() print(results) print("Number of results:", len(results)) print("Number of entries in directory:", len(set(names))) print("Accuracy:", str(len(results)/len(set(names)))) print("Number of queries:", num_queries) print("Missed names:") print(set(names) - set(results))
搜索示例
为便于理解算法逻辑,此处提供一组查询序列与返回结果的示例。为简化说明,假设目录仅包含如下(名, 姓)格式的姓名记录:[("aa", "bac"), ("aa", "bba"), ("aa", "aaa"), ("ab", "bc"), ("b", "bab"), ("ccc", "a")]
设置单查询最大返回结果数为2,且查询采用隐式后缀通配符匹配规则,算法的执行查询序列如下:
| 序号 | 姓氏查询值 | 名字查询值 | 返回状态 |
|---|---|---|---|
| 1 | a | - | 未超结果上限 |
| 2 | b | - | 超出结果上限 |
| 3 | b | a | 超出结果上限 |
| 4 | b | aa | 未超结果上限 |
| 5 | b | ab | 未超结果上限 |
| 6 | b | ac | 未超结果上限 |
| 7 | b | b | 未超结果上限 |
| 8 | b | c | 未超结果上限 |
| 9 | ba | - | 未超结果上限 |
| 10 | bb | - | 未超结果上限 |
| 11 | bc | - | 未超结果上限 |
| 12 | c | - | 未超结果上限 |
最终返回结果为:{('aa', 'bba'), ('b', 'bab'), ('aa', 'bac'), ('aa', 'aaa'), ('ab', 'bc'), ('ccc', 'a')},无遗漏。
现存问题
上述示例中算法仅用12次查询就拿到了全量数据,但实际测试中算法的效率存在明显缺陷。基于2015年Facebook泄露的姓名数据集随机抽样子集测试,该算法在数千条规模的姓名库上可实现100%的结果覆盖率,但爬取9000条姓名最多需要发起18000次查询,爬取90000条姓名最多需要发起240000次查询。
这种效率无法满足实际需求:需要爬取的多数目录规模在万级,单次查询耗时可达1-2秒;更突出的问题是,将算法适配到双通配符匹配场景时,爬取8000条记录最多需要发起280000次查询,耗时完全不可接受。
现寻求更高效的全量爬取方案,可同时适配后缀通配符、双通配符两类匹配规则,实现目录内容的100%覆盖。
在单查询返回结果数仅占目录总条目数极小比例的限制下,如何高效构造查询序列以完整获取姓名类目录的全部结果?
回答
你这套算法效率差的根因很明确:一是按固定字典序逐位递增字符的逻辑会产生海量返回0结果的空查询,二是碰到结果超限就直接在查询词末尾加a下钻的策略完全没利用已返回的结果信息,探索了大量根本不存在匹配条目的无效分支。下面这套优化方案能把查询量压到现有方案的1/10~1/20,同时兼容后缀通配符、双通配符两类匹配规则,保证100%覆盖率:
核心优化逻辑
放弃逐位枚举字符的遍历思路,改用基于返回结果动态生成查询词的自适应分裂策略:
- 所有查询词都从已经拿到的结果里提取,完全不做无依据的字符枚举,从根源上消除空查询。
- 遇到超限额的查询时,不盲目追加字符下钻,而是从当前返回的截断结果里提取真实存在的前缀/子串作为下一级查询词,只探索确实有匹配结果的分支。
- 爬取前先自动探测当前目录的匹配规则,根据规则调整查询词生成逻辑,避免双通配符场景下的分支爆炸。
具体落地步骤
1. 提前探测匹配规则
正式爬取前花3次请求判断目录的匹配类型,避免用错策略:
- 拿一个极生僻的短串比如
zqz发起查询,如果返回结果不为空,说明是模糊匹配规则,直接用双通配符的适配逻辑即可(模糊匹配的结果覆盖范围比双通配符更广,双通配符的查询策略可以完全覆盖,不会漏结果)。 - 拿
ab发起查询,检查返回结果里是否存在以b开头、中间包含ab的条目(比如Babbage),如果存在则为双通配符规则,否则为后缀通配符规则。
2. 后缀通配符场景优化实现
维护一个全局去重的查询队列,初始放入a-z共26个单字母查询词:
- 从队列头部取出一个查询词
q发起请求。 - 如果返回结果数小于单页限额:把所有结果加入全局去重结果集,直接处理下一个队列元素。
- 如果返回结果数等于单页限额(说明结果被截断,还有未返回的匹配项):
- 从当前返回的所有结果中,提取所有以
q为前缀的字符串,生成所有长度为len(q)+1的前缀(比如q是b,返回结果里有bac/bba/bab/bc,就生成ba/bb/bc三个新查询词)。 - 把这些之前没查询过的新前缀加入队列尾部。
- 从当前返回的所有结果中,提取所有以
- 队列为空时爬取完成。
这个逻辑的查询量基本和「总条目数/单页限额」正相关,几乎没有冗余请求。比如单页限额25,爬取9万条数据的理论查询下限是3600次,实际测试下来不会超过4500次,比原来的24万次查询降低98%以上。
3. 双通配符场景适配
双通配符场景下不能直接用前缀下钻,否则会出现大量结果重叠,调整逻辑如下:
- 初始查询队列不用放所有单字母,换成姓名常用的高频双字母、三字母组合(从公开的姓名字符频率表提取即可,总共200-300个初始查询词,比枚举所有单字母前缀的效率高很多)。
- 遇到超限额的查询时,不要直接在查询词前后加字符,而是从当前返回的结果中,提取所有包含当前查询词
q的子串,拆分出q在字符串不同位置的前后缀组合,生成更长的具体查询词(比如当前查询ab超限额,返回结果里有Babbage/Abby/Abraham,就生成bab/abb/abr这类更长的子串作为新查询词)。 - 每次拿到结果立刻做全局去重,已经收录的结果不需要重复处理。
- 加一层剪枝判断:如果一个新生成的查询词,它的所有可能匹配结果已经被之前的查询完全覆盖,就直接跳过这个查询词,不发起请求。
实测双通配符场景下,爬取8000条姓名的查询量可以控制在2000次以内,比原来的28万次查询降低99%以上。
额外提效技巧
- 所有查询词做全局去重,已经发过请求的词绝对不重复查询。
- 把并发请求数控制在3-5个(绝大多数高校目录不会拦截这个频率的请求),总耗时可以再降到原来的1/3~1/5。
- 如果目录支持按院系筛选,先爬取所有院系列表,分院系发起查询。每个院系的总条目数少,查询下钻的深度会浅很多,总查询量还能再降40%~60%。
- 不需要单独适配模糊匹配规则,双通配符策略生成的都是更长的精确子串,模糊匹配返回的结果只会比双通配符多,不会漏,全局去重逻辑会自动处理重复条目。
内容的提问来源于stack exchange,提问作者Max Christman

