I live in Dubai. When I want a plumber, a nursery, or a lab that runs one specific blood test, the search experience is worse than it should be for a city this size. Google Maps knows all of it. There is just no way to browse it, filter it, or ask a structural question of it.我住在迪拜。每当我想找水管工、托儿所或进行特定血液检查的实验室时,对于这样一个规模的城市来说,搜索体验实在太差了。Google 地图虽然掌握了所有这些信息,但却无法进行浏览、筛选或提出结构化问题。
That gap is the idea, and it was mine before it was a project. I wanted something that could answer questions about a city rather than about one business at a time.这种差距就是我的灵感来源,在它成为一个项目之前,这仅仅是我的一个想法。我想要一个能够回答关于整个城市的问题,而不是一次只能查询一家企业的工具。
So I built it. Business Directory Toolkit is MIT-licensed and turns a city into a browsable business directory, and directory.pooyagolchian.com is a live Dubai deployment standing on 1,400 requests to SearchApi's Google Maps engine. I am writing it up here as part of their developer ambassador programme, which backs developers who ship something real on their APIs and leaves the work in your name.所以我把它做了出来。Business Directory Toolkit 是一个采用 MIT 许可的项目,它能将一个城市转化为可浏览的商业目录。directory.pooyagolchian.com 是一个运行中的迪拜部署实例,仅消耗了 1,400 次 SearchApi 的 Google 地图引擎请求。我在这里撰写这篇文章,作为他们开发者大使计划的一部分,该计划旨在支持那些在其 API 上构建真实产品并以开发者名义发布成果的开发者。
Most articles about scraping Google Maps stop at one API call and a console.log. That is the part that already works. Everything expensive happens after it.大多数关于抓取 Google 地图的文章都只停留在一次 API 调用和 console.log 上。那部分工作早已不成问题,真正昂贵的工作都在那之后。
Why build against a city you already know?为什么要针对一个你已经很熟悉的城市进行构建?
Business Directory Toolkit had a real target from day one. Not "build a directory" in the abstract, but this city, where I could check the output against a place I actually know. When the crawler came back with a couple of hundred nurseries, I could tell whether that smelled right.Business Directory Toolkit 从第一天起就有明确的目标。它不是抽象地“构建一个目录”,而是针对我所在的这座城市,我可以根据自己真正了解的地方来校验输出结果。当爬虫返回几百家托儿所时,我能一眼看出数据是否准确。
That turned out to matter more than any architectural decision. Every headline defect in this repository was found by reading real output while the test suite sat green.事实证明,这一点比任何架构决策都重要。这个仓库中每一个重大的缺陷,都是在测试套件显示通过的情况下,通过阅读真实输出结果发现的。
What is a business directory actually for?商业目录的真正用途是什么?
Four things, and only one of them is the website.有四个用途,而网站只是其中之一。
The obvious one is the browsable surface. One crawl produced 14,981 business pages, 782 neighbourhood-by-category landing pages, 81 category pages and 40 neighbourhood hubs. That is 15,887 URLs in the sitemap, generated from a single dataset.最显而易见的是可浏览界面。一次抓取产生了 14,981 个企业页面、782 个“社区+分类”落地页、81 个分类页面和 40 个社区中心。这意味着通过单一数据集,在网站地图中生成了 15,887 个 URL。
The second is sales prospecting, and this is the one that pays. The same crawl that produces a public directory also tells you which businesses have no website, which have never published opening hours, which are rated badly despite real trade, and which have almost no reviews. Those are four different pitches to four different buyers.第二个用途是销售挖掘,这也是最能变现的部分。同样的抓取过程不仅能生成公共目录,还能告诉你哪些企业没有网站、哪些从不发布营业时间、哪些评分很低但业务真实、以及哪些几乎没有评论。这对应着四种针对不同买家的推销策略。
The third is market research. The corpus answers questions a consultant charges for. Which of 40 neighbourhoods concentrate which of 81 categories. What the median review count looks like at each rating band. How many businesses in a city of this size hold an exact five stars.第三个用途是市场研究。这些语料库可以回答咨询顾问收费才能解答的问题:40 个社区中哪些分布着 81 个分类中的哪些业务?每个评分区间的平均评论数是多少?在这个规模的城市中,有多少企业拥有完美的五星好评?
The fourth is boring and useful. Reverse phone lookup. Numbers get normalised to E.164 at load time, so the live search matches a +971 number however someone types it.第四个用途虽然枯燥但非常实用:反向电话查询。电话号码在加载时会被统一规范化为 E.164 格式,因此无论用户如何输入,实时搜索都能匹配到 +971 开头的号码。
Why use SearchApi instead of scraping Google Maps?为什么要使用 SearchApi 而不是直接抓取 Google 地图?
A single Google Maps query cannot enumerate a city. It stops at roughly 200 results, and page 11 does not come back as an empty list, it comes back with no local_results key at all. I committed that probe to the repository as a test fixture so nobody has to spend a credit rediscovering it.单次 Google 地图查询无法枚举整个城市。它在约 200 个结果处就会停止,并且第 11 页返回的不是空列表,而是根本没有 local_results 键。我已将该探测结果作为测试用例提交到仓库中,这样其他人就不必浪费额度去重新发现这一点了。
If you were expecting 60, that is the ceiling on the Places API Text Search, which returns 20 results across three pages and stops. The Maps engine goes considerably deeper before it gives out. It still gives out, and that is the part that decides how you build.如果你期待 60 个结果,那是 Places API 文本搜索的上限,它在三页内返回 20 个结果后就会停止。地图引擎在耗尽前能走得更远。它最终还是会耗尽,而这正是决定你如何构建系统的关键。
That one measurement decides the entire architecture. Tiling is mandatory, not an optimisation. Dubai is 44 geographic squares crossed with 40 categories, and the only dial that moves the bill is how deep each pair is allowed to paginate.这一衡量指标决定了整个架构。分块(Tiling)是必须的,而不是一种优化手段。迪拜被划分为 44 个地理方块与 40 个分类的组合,唯一能影响账单的因素就是每一对组合允许分页的深度。
SearchApi fits that shape better than the alternatives for reasons that are structural rather than promotional.SearchApi 在这方面的表现优于其他替代方案,其原因在于结构上的优势而非推广宣传。
SearchApi takes Google's own location format straight through, as ll=@lat,lng,zoom. Tiling a city means expressing a coordinate and a zoom level, which is exactly the parameter I already had, so no geocoding service sits in the middle.SearchApi 直接支持 Google 自己的位置格式,即 ll=@lat,lng,zoom。对城市进行分块意味着表达坐标和缩放级别,这正是我已有的参数,因此中间不需要任何地理编码服务。
One SearchApi request costs one credit. Because the unit of billing is the unit of work, the whole cost model collapses into a 3x3 lookup table, and pnpm plan prints the invoice before anything is spent. Budget guards are only possible because the arithmetic stays that simple.一次 SearchApi 请求消耗一个额度。由于计费单位就是工作单位,整个成本模型简化为 3x3 的查找表,pnpm plan 可以在花费任何额度前打印出账单。只有因为算术如此简单,预算控制才成为可能。
The response carries fields that replace other services. Roughly 99.8% of results arrive with country_code and city, which drives the in-city filter with no geocoder. Every single result carries gps_coordinates, which is how the pipeline reassigns a business to the tile it actually sits in rather than the one whose query found it. About half of them sat somewhere other than where I looked, because Google answers from a radius.响应中携带的字段可以替代其他服务。约 99.8% 的结果带有 country_code 和 city,这使得无需地理编码器即可实现城市内筛选。每一个结果都带有 gps_coordinates,流水线正是通过它将企业重新分配到它实际所在的方块中,而不是查询时找到它的那个方块。大约一半的结果位于我搜索范围之外,因为 Google 是基于半径进行回答的。
place_id arrives on 100% of results and stays stable. It is the dedup key, the database partition key, and the only value the takedown suppression list is allowed to hold.place_id 在 100% 的结果中都会出现且保持稳定。它是去重键、数据库分区键,也是下架抑制列表中唯一允许持有的值。
Failure is predictable enough that the retry logic is two lines. Retry on 429 and on anything at 500 or above. A 400 throws immediately, which is correct, because a malformed query does not improve on the fourth attempt.故障是可以预见的,因此重试逻辑只需两行:针对 429 错误以及 500 及以上错误进行重试。400 错误会立即抛出,这是正确的,因为格式错误的查询在第四次尝试时也不会变好。
The last one I did not expect. google_autocomplete costs one credit per category and returns suggestions ordered by real query popularity. That turns "which pages should I build" from a guess into a measurement.最后一个是我没想到的:google_autocomplete 每个分类消耗一个额度,并返回按真实查询热度排序的建议。这使得“我应该构建哪些页面”从猜测变成了基于数据的决策。
How do you crawl an entire city, step by step?如何一步步抓取整个城市?
Ten commands. The required spine is five of them, and only one spends money on the corpus.十个命令。核心骨干是其中五个,且只有一个会消耗语料库的预算。
Plan the crawl. Costs nothing, issues nothing, writes nothing.规划抓取。不产生费用,不发出请求,不写入数据。
pnpm plan --city dubaiIt prints 1,250 first-page jobs against a 3,170 worst case and a 2,000 default budget, plus a warning that full depth overruns by 1,170. Of 1,760 possible tile-and-category pairs, 510 are dropped before a single credit is committed, because crawling law firms in the desert spends money to find nothing.它会打印出 1,250 个首屏任务,最坏情况下为 3,170 个,默认预算为 2,000 个,并警告完全深度抓取会超出 1,170 个。在 1,760 个可能的“方块-分类”组合中,有 510 个在消耗任何额度前就被剔除,因为在沙漠里抓取律师事务所只会浪费金钱却一无所获。
Crawl. The only irreversible spend in the project, so it refuses to run without --yes.抓取。这是项目中唯一不可逆的支出,因此如果没有 --yes 参数,它拒绝运行。
pnpm crawl --dry-run
pnpm crawl --city dubai --yes --budget 200Four guards stack here. The mandatory flag, the free dry run, a hard budget stop, and the plan-time drops. A page is only bought if the previous page came back full and at least 30% of it was new.这里堆叠了四重保护:强制标志、免费试运行、硬预算上限以及规划阶段的剔除。只有当上一页返回结果已满且至少 30% 是新内容时,才会购买下一页的额度。
Every raw response gets written to disk before anything parses it. That habit is the single best decision in the project, and I will come back to why.每一份原始响应在解析前都会写入磁盘。这个习惯是项目中做得最好的决策之一,稍后我会解释原因。
Seed the taxonomy. No model call, no network, no credits.种子分类法。无需模型调用,无需网络,无需额度。
pnpm seed-taxonomy132 ordered keyword rules classify the head of the category vocabulary.132 条有序关键词规则对分类词汇表的头部进行了分类。
Classify the tail. This spends Anthropic tokens and never SearchApi credits.对尾部进行分类。这会消耗 Anthropic 的 Token,但绝不会消耗 SearchApi 的额度。
pnpm classify --yesHere is the idea worth stealing. The obvious approach sends every business to a model, which scales with your dataset forever. But category vocabulary saturates while business count does not. My 15,246 businesses contained only 1,787 distinct category strings, which is 8.5 times fewer items. The keyword rules handle the head, so the model only ever sees a tail of 537 strings, once, across every crawl this project will ever run. A re-crawl that introduces no new strings prints a marginal cost of zero and exits.这是一个值得借鉴的思路。显而易见的方法是将每家企业都发送给模型,这会随着数据集的增长而无限扩展。但分类词汇表是饱和的,而企业数量不是。我的 15,246 家企业仅包含 1,787 个不同的分类字符串,项目数量减少了 8.5 倍。关键词规则处理了头部,因此模型只需要处理 537 个字符串的尾部,且对于本项目运行的每一次抓取,这都只需处理一次。如果重新抓取没有引入新的字符串,则边际成本为零并直接退出。
Load. Dedupe, drop suppressed listings, assign each business to its nearest tile by coordinates, then normalise.加载。去重、剔除被抑制的列表、通过坐标将每家企业分配到最近的方块,然后进行规范化。
pnpm load --dry-run
pnpm load --yesFive acceptance gates print on every run. At least 10,000 unique businesses, 90% phone coverage, 99% taxonomy coverage, unique slugs, and zero rows outside the country. My crawl passes at 92.2% phone coverage and 99.5% taxonomy coverage.每次运行都会打印五个验收门槛:至少 10,000 家独立企业、90% 的电话覆盖率、99% 的分类覆盖率、唯一的 URL 别名以及零个境外数据行。我的抓取结果通过了验收,电话覆盖率为 92.2%,分类覆盖率为 99.5%。
Two of those thresholds started higher and came down on evidence. I wanted 95% phone coverage. A probe of the detail endpoint across 15 businesses recovered exactly one extra number, and it was an Indian number correctly rejected. So 92.2% is a ceiling, not a shortfall, and the gate now says so.其中两个门槛最初设置得更高,后来根据证据进行了下调。我曾想要 95% 的电话覆盖率,但对 15 家企业进行详情端点探测后,仅多找回了一个号码,而且还是一个错误的印度号码。因此 92.2% 是上限而非不足,门槛现在也相应调整了。
Then the optional passes. pnpm demand asks autocomplete what people actually search. pnpm leads scores prospects. pnpm export writes CSV, JSON or NDJSON for a CRM.然后是可选步骤。pnpm demand 向自动补全功能询问人们实际搜索的内容。pnpm leads 对潜在客户进行评分。pnpm export 为 CRM 导出 CSV、JSON 或 NDJSON。
Why archive every raw response before parsing it?为什么要先归档每一份原始响应再进行解析?
Because every response is stored untouched before parsing, everything downstream is free to re-run.因为每一份响应在解析前都被原封不动地存储,后续的所有步骤都可以自由地重新运行。
pnpm load --from-archiveNo network, no credits, full rebuild. I changed the taxonomy, the ranking constants and the normalisation repeatedly over weeks, all against one paid crawl. The 573-test suite runs in 3.5 seconds against committed fixtures and spends nothing.无需网络,无需额度,可完全重建。几周以来,我反复修改了分类法、排名常量和规范化逻辑,所有这些都基于同一次付费抓取。573 个测试用例在 3.5 秒内运行完毕,基于已提交的测试装置,不消耗任何资源。
That archive also produced the ugliest moment in the project. A single unresolvable path in the data loader made Next's file tracer give up and wildcard the directory, which put 1,400 raw crawl files carrying 20,226 verbatim Google review snippets inside the deployment bundle. Some of them named individual employees. Nothing read them and all of them would have shipped.那个归档文件也造成了项目中“最丑陋”的时刻。数据加载器中一个无法解析的路径导致 Next.js 的文件追踪器放弃并通配了整个目录,这使得 1,400 个包含 20,226 条逐字 Google 评论片段的原始抓取文件被打包进了部署包中。其中一些评论甚至提到了员工姓名。没人读过它们,但它们本来会被发布出去。
The related bug is worse and more interesting. Review theme extraction rewards terms frequent for one business and rare everywhere else, which is precisely the shape of a staff member's name. The first live run produced a Sofitel listing tagged with three employees' first names. A blocklist would have been endless. The fix is a property instead, since a real theme recurs across many businesses while a person's name belongs to one.相关的 Bug 更糟糕也更有趣。评论主题提取功能会奖励那些在某一家企业出现频率高但在其他地方罕见的术语,这恰好也是员工姓名的特征。第一次上线运行产生了一个 Sofitel 的列表,上面标记了三名员工的名字。黑名单根本列不完。修复方法是改用属性,因为真正的主题会在许多企业中反复出现,而一个人的名字只属于一家企业。
What does the live demo actually do?实时演示版到底有什么功能?
directory.pooyagolchian.com is live and prerendered on CloudFront.directory.pooyagolchian.com 已上线并预渲染在 CloudFront 上。
Search works. A query for dentist returns 512 matches. Typing a +971 number returns the business that owns it. Typeahead answers from a JSON endpoint in one round trip. Business pages carry LocalBusiness structured data, a canonical, both phone formats, and an honest label saying the rating came from Google rather than from me.搜索功能正常。查询“牙医”返回 512 个匹配项。输入 +971 开头的号码会返回拥有该号码的企业。类型预判(Typeahead)通过 JSON 端点在一次往返中完成响应。企业页面带有 LocalBusiness 结构化数据、规范链接、两种电话格式,并诚实地标注了评分来自 Google 而非我本人。
The homepage renders 14,981 businesses across 81 categories and 40 neighbourhoods, with 92% carrying a phone number.主页渲染了 81 个分类和 40 个社区中的 14,981 家企业,其中 92% 带有电话号码。
It also carries a chart I did not expect to build. Median review count climbs with rating exactly as you would hope, running 12, 18, 48, 93, 139, and then collapses to 11 at exactly 5.0. There are 2,283 Dubai businesses holding a perfect score, and the typical one has eleven reviews behind it. A perfect score is the bottom of the evidence, not the top of the scale, which is why listings rank on a credibility-weighted mean instead of a raw average.它还包含了一个我没预料到要构建的图表。平均评论数随评分的增加而增加,正如预期的那样,依次为 12、18、48、93、139,但在评分正好为 5.0 时骤降至 11。迪拜有 2,283 家企业拥有完美评分,而典型的企业背后只有 11 条评论。完美评分是证据的下限,而非规模的上限,这就是为什么列表按可信度加权平均值而非原始平均值进行排名。
Some things genuinely do not exist yet. There is no pagination, so large categories cap at 120 rendered rows and disclose it in plain text. Sorting filters what is already on the page rather than querying the server. The DynamoDB path is written and nothing reads it. I would rather say that than let you find out by clicking.有些功能确实还没做。没有分页,所以大型分类在渲染 120 行后就会截断并以纯文本披露。排序功能是对页面上已有的内容进行筛选,而不是查询服务器。DynamoDB 的路径已经写好,但没有任何程序读取它。我宁愿直接说出来,也不想让你在点击时才发现。
How does a directory become a lead generation engine?目录如何变成潜在客户挖掘引擎?
This is where a directory stops being a content project.这是目录不再仅仅是一个内容项目的地方。
One crawl produces two products. The 1,400 requests that built a public website also built four prospect lists, offline, at zero marginal cost. That is one acquisition cost against two outputs, and it is the only version of the growth-engine claim I am willing to defend.一次抓取产生两个产品。构建公共网站的那 1,400 次请求也离线构建了四个潜在客户列表,边际成本为零。这是一种获取成本对应两种产出,也是我唯一愿意捍卫的“增长引擎”说法。
The scoring took two attempts. My first version ranked leads by the same credibility-weighted rating the directory sorts by, which sounds sensible and inverts itself in practice. Across 641 real leads the score correlated with review count at negative 0.28, so the more trade a business had, the further down my call sheet it went. The list was topped by bank ATMs. A 3.6-rated hospital with 5,562 reviews sat at number 522 of 641.评分逻辑尝试了两次。我的第一个版本使用与目录排序相同的可信度加权评分来对潜在客户进行排名,这听起来合理,但在实践中却恰恰相反。在 641 个真实潜在客户中,评分与评论数的相关性为负 0.28,这意味着企业的业务越多,它在我的拨打列表上就越靠后。列表顶部是银行 ATM 机。一家 3.6 分、拥有 5,562 条评论的医院排在 641 个中的第 522 位。
The fix generalises into one rule worth stealing. The health term must never be a function of the quantity the signal measures. Score on establishment instead, and the correlation flips to positive 0.08.修复方法概括为一条值得借鉴的规则:健康指标绝不能是衡量信号数量的函数。改用“成立时间”进行评分,相关性就变成了正 0.08。
What comes out is a callable list. On my Dubai corpus, 3,820 reachable prospects have no website and 892 have never published hours. Compose the filters and you get 321 restaurants with 20 or more reviews and no website, which is a web agency's afternoon.最终产出的是一份可拨打的列表。在我的迪拜语料库中,有 3,820 个可联系的潜在客户没有网站,892 个从未发布过营业时间。组合这些筛选条件,你会得到 321 家拥有 20 条以上评论但没有网站的餐厅,这足够一家网络代理商忙活一个下午了。
Two caveats I will not bury. Those are reachable prospects, meaning a phone number is required, so they run smaller than the raw count of businesses with the gap. And a lead list is research, not permission to contact anyone. The CLI prints that on every run.有两个注意事项我不会隐瞒:这些是“可联系的”潜在客户,意味着必须有电话号码,因此数量比原始的缺失数据企业数要少。此外,潜在客户列表是研究成果,不是联系任何人的许可。CLI 在每次运行时都会打印此声明。
Why is categorisation the real Yellow Pages problem?为什么分类是真正的《黄页》难题?
The reason this reaches directory scale rather than staying a scrape is the taxonomy pass, and it costs almost nothing.之所以能达到目录规模而不是仅仅停留在抓取层面,关键在于分类步骤,而且它的成本几乎为零。
Classify the vocabulary instead of the businesses and you put 8.5 times fewer items in front of a model. Handle the head with deterministic rules and the model never sees 99.5% of your corpus. What you get back is a two-level browse taxonomy that supports 782 neighbourhood-by-category pages, and it is permanent, because the committed map always wins over both the rules and the model. The next crawl re-applies it for free. So does the next city.对词汇表进行分类而不是对企业进行分类,你面对模型的项目数量减少了 8.5 倍。用确定性规则处理头部,模型就不会看到 99.5% 的语料库。你得到的是一个两级浏览分类法,支持 782 个“社区+分类”页面,而且它是永久的,因为已提交的地图总是胜过规则和模型。下一次抓取会免费重新应用它。下一个城市也是如此。
Thin pages stay out of it. A category needs at least three businesses in a neighbourhood before that page exists at all, which keeps the surface large without turning it into spam.精简页面不包含在内。一个分类在某个社区至少需要三家企业才会存在对应的页面,这保持了规模,同时避免了垃圾内容。
How do you port the crawl to another city?如何将抓取程序移植到另一个城市?
The crawl is genuinely city-agnostic. Tiles, categories, bounding boxes, country code and phone region all live in one JSON file, and adding a city requires no code change.抓取程序是真正的城市无关型。方块、分类、边界框、国家代码和电话区域都存放在一个 JSON 文件中,添加一个城市无需更改代码。
pnpm cities generate --name "Lisbon"That reads OpenStreetMap and writes the config at zero SearchApi cost. It refuses to fabricate, so a city with too few candidate centres throws rather than falling back to an even grid, and every generated config is stamped unverified until somebody actually crawls it. Verification stores evidence rather than a boolean, because a bare verified: true invites someone to flip it and producing a false one costs a real crawl.它读取 OpenStreetMap 并以零 SearchApi 成本写入配置。它拒绝捏造数据,因此候选中心太少的城市会抛出错误,而不是退回到均匀网格,并且每个生成的配置都被标记为“未验证”,直到有人真正抓取它。验证存储的是证据而不是布尔值,因为一个简单的 verified: true 会引诱人直接修改它,而产生一个虚假的验证需要真实的抓取成本。
The web layer still says Dubai in a few places. Porting the crawl is a data file. Porting the site is still a small patch, and I would rather write that down than imply otherwise.Web 层在某些地方仍然写着“迪拜”。移植抓取程序只需一个数据文件。移植网站仍然需要一点小修改,我宁愿写下来也不想暗示它很简单。
What I am not claiming我没有声称的内容
The toolkit has been proven on exactly one city, and that run stopped after 18 of the 44 configured tiles, so even Dubai is not finished. I have taken no latency, Core Web Vitals, ranking or traffic numbers, so I am quoting none. The repository ships the pipeline and never the corpus, which means both the raw archive and the built dataset are git-ignored and CI fails the build if either gets committed. Running this requires your own key and your own crawl.该工具包仅在一个城市上经过验证,而且那次运行在 44 个配置方块中的 18 个之后就停止了,所以即使是迪拜也没有完成。我没有记录延迟、核心 Web 指标、排名或流量数据,因此我不会引用任何这些数字。仓库提供流水线而非语料库,这意味着原始归档和构建的数据集都被 git 忽略,如果提交了其中任何一个,CI 就会构建失败。运行此项目需要你自己的密钥和你自己的抓取。
That last constraint is deliberate, and it is the part I find most interesting commercially. A toolkit that does nothing without a SearchApi key means every serious user opens an account. Shipping a hosted API or a paid dataset would have inverted that, because then I would hold the only key that mattered.最后一个限制是深思熟虑的,也是我在商业上觉得最有趣的部分。一个没有 SearchApi 密钥就无法工作的工具包,意味着每个严肃的用户都会开设一个账户。如果发布托管 API 或付费数据集,情况就会反过来,因为那样我就掌握了唯一重要的密钥。
The code is on GitHub. Take the ranking, the phone parsing, or the taxonomy pass on its own if that is all you need, since packages/core is pure and has no idea what a city is.代码在 GitHub 上。如果只需要排名、电话解析或分类步骤,可以单独拿去用,因为 packages/core 是纯净的,根本不知道什么是城市。
