DIY Rental Platform — Public Marketplace (Astro)
The anonymous, SEO-critical public face of the Malaysian rental marketplace: a new Astro 7 app at rent.gremlin.my that server-renders search and listing pages, reads Go directly for anonymous public data, and ships no island framework at all. A 22-question grilling session on 2026-08-26 settled the domain, the indexing rules, image handling, and the Seeker-to-Tenant handoff into the panel — and uncovered three gaps in slice 1's schema that must be fixed before its public API contract freezes.
摘要
这是马来西亚租房市场对外公开的门面:一个部署在 rent.gremlin.my 的全新 Astro 7
应用,匿名访问、以 SEO 为核心,所有页面都在服务端渲染,公开数据直接读取 Go,并且完全不引入任何
island 框架。切片 1 证明房东愿意挂牌,切片 2 证明双方愿意成交;没有这个应用,两者都只是内部工具。
一位 Seeker——没有账号的人——通过 Google 找到一条马来西亚租房信息,浏览并筛选 而页面不刷新,然后点击进入预约看房。
成功标准
- 房源详情页可被索引,分享到 WhatsApp 时能正确渲染卡片
- 筛选不刷新页面,且每个结果集都有可分享的 URL
- 任何公开页面都不会渲染房东姓名、电话、邮箱或详细街道地址
- 即使 API 挂掉,站点仍能提供可用的页面
- 曾被索引或分享过的 URL 永远有效——slug 永不改变(ADR-0057)
- 点击"Arrange Viewing"会进入该条房源的预约表单,即使 Seeker 中途必须注册账号
架构概览
数据流
browser ──► Cloudflare edge (cached HTML)
└─► Astro SSR on Workers
└─► GET {RENTAL_API_URL}/api/public/rental/listings?…
RENTAL_API_URL = https://api.kokweng.net (origin only)
只有一个模块 src/lib/rental-api.ts,导出 searchListings(params) 和
getListing(slug)。它负责路径常量、查询串拼装、请求超时和响应类型。任何页面都不直接
调用 fetch。
应用结构
apps/rental/
astro.config.mjs ASTRO_ADAPTER pattern, copied from quiz; no integrations
src/
layouts/Layout.astro ClientRouter + prefetch
pages/
index.astro
property/[slug].astro
sitemap.xml.ts
404.astro
unavailable.astro rendered for the 503 degraded state
components/
FilterForm.astro <form method="GET"> + inline requestSubmit() on change
ListingCard.astro
PhotoGallery.astro CSS scroll-snap + anchors, no JavaScript
lib/
rental-api.ts the only module that calls fetch
areas.ts allowlist of indexable areas
image.ts /cdn-cgi/image URL builder
页面包括 /(筛选表单 + 结果)、/property/{slug}(房源详情)、
/sitemap.xml,以及静态的 /robots.txt。筛选条件完全存放在查询串中:
?area=&min_rent=&max_rent=&type=&furnishing=&page=
关键决策
市场站点用哪个域名?
rent.gremlin.my——沿用平台已有 zone 下的子域名。
账号下有两个 zone:gremlin.my(quiz)和 kokweng.net
(api.、auth.、panel.)。选择 kokweng.net
意味着市场站点和 panel 共享同一个可注册域名,因此"Arrange Viewing"的交接以及注册后的返回都属于
同站(same-site),不需要携带任何跨域状态。
暂缓:启用一个独立的 .my 域名,对马来西亚的 Seeker 来说更可信。但 SEO 权重会累积在
最先上线的那个域名上——如果产品日后值得一个独立域名,要趁站点还没有值得保留的排名时尽早迁移。
走 Bun 代理,还是直接调用 Go?
匿名读取直接由 SSR 调用 Go,作为例外记录在 ADR-0056 中。
AGENTS.md 要求前端请求走 3100 端口的 Bun,以便转发认证 cookie 并规避跨域问题。
这两点对匿名的服务端到服务端读取都不适用。走代理只会多一跳,还让一个公开的营销站点依赖 Bun 的可用性。
已否决:机械地遵守现有规则。这里记录为有据可查的例外,而不是无声的绕过,让下一位读者看到的是一个 决策,而不是一处违规。
用哪个 island 框架?
完全不用。筛选由一段五行的内联脚本自动提交。
这个站点几乎不需要客户端状态——筛选是 GET 表单,导航交给 ClientRouter,相册是 CSS
scroll-snap。两个独立的 Astro 应用 quiz 和 plans 都是零集成。
纯表单会迫使 Seeker 每改一项就按一次搜索,所以 FilterForm.astro 在
change 时调用 form.requestSubmit()——这是渐进增强,没有运行时、没有依赖、
没有 hydration。
已否决:Qwik(platform 里带着约 10 行打包器 workaround,还锁定在 beta 版)和 Solid
(会为这块区域再引入第二个 island 框架而毫无收益)。如果将来某处确实需要状态,答案是
Qwik,因为 shared-ui 已经有表单控件——加上 qwik() 只是
一行配置,不改动任何页面。
服务端渲染 + ClientRouter
- 浏览器调用 API
- 从不
- 整页 CDN 缓存
- 可以
- 索引表现
- 强
- Hydration JS
- 无
ClientRouter 拦截链接点击和表单提交,取回下一页的 HTML 并替换 DOM——同时保留滚动位置和焦点。platform 已经在用。
由 island 在客户端 fetch
- 浏览器调用 API
- 会——需要 CORS
- 整页 CDN 缓存
- 被削弱
- 索引表现
- 较弱
- Hydration JS
- 每个搜索页都有
代价是公开端点要处理 CORS、到处都是 hydration JavaScript,以及更弱的索引与缓存表现——而这三点恰恰是这个应用赖以存在的强项。
仓库中既有的结论
从探查 frontend/astro 和切片 1 的 schema 得到五项发现,其中三项修正了切片 1 的设计。
在 platform 旁新建应用需要一份 ADR
政策
AGENTS.md 规定,只有"当某份 ADR 记录了产品与数据边界"时,独立部署的产品才可以与
platform 应用并存,quiz 应用是唯一已获批的例外(ADR-0054)。因此这项工作从 ADR-0056 开始,
而不是从建一个目录开始。
所有前端 API 调用本应经过 Bun
已取例外浏览器和 SSR 都调用 3100 端口的 Bun,再由它代理 8080 上的 Go;直接调用 Go 被标为错误做法。 但这两条理由对匿名公开读取都不成立。
另有值得记录的偏差:AGENTS.md 声称 automation、bulk、
exports、ai、reports、notifications 和
leave 都有代理。而网关的 routes/index.ts 实际上只代理了
mobile/sync 和 fieldforce。
这里的 island 是 Qwik,而 @astro/core 是空的
修正切片 1
切片 1 的设计没有说明适用哪个 island 框架,还声称复用 @astro/core——而
frontend/astro/packages/core/ 是空的。只有 shared-ui
存在,里面是两个 Astro 组件和七个 Qwik 表单控件。
切片 1 schema 中的三处缺口
有时限rental_property_photos没有存图片尺寸——想做到零 CLS 的页面,根本没有数据可用于设置width/heightarea是自由文本字段,不是枚举也不是引用表,房东会自己造出各种写法rental_listings完全没有title字段,因此页面标题和 slug 都必须由结构化字段拼装
Astro 6 已落后一个大版本
版本偏差
platform、quiz 和 plans 装的都是 6.4.8;最新是 7.2.7,
@astrojs/cloudflare 为 14.2.5、@astrojs/node 为 11.1.4。Qwik 根本没有
稳定的 2.x——最新是 2.0.0-beta.41,而 platform 一直跑在
2.0.0-beta.32 上。
范围
范围之内
- 新应用
frontend/astro/apps/rental,开发用 node 适配器、构建用 Cloudflare,沿用quiz和platform已有的ASTRO_ADAPTER模式 - ADR-0056,记录产品边界与直连 Go 的例外
- 搜索页:地区、价格区间、房产类型、家具配置
- 房源详情页
/property/{slug},含照片、租赁条款、房屋规则和"Direct from Owner"标识 - "Arrange Viewing"链接进入切片 2 在 panel 中的预约入口路由
- SEO:每条房源的标题与描述、Open Graph、canonical URL、JSON-LD、
sitemap.xml、robots.txt - 缓存头,让 Cloudflare 承接重复流量,包含
stale-if-error - API 不可用时的降级状态
- 通过
/cdn-cgi/image做边缘图片压缩,避免 5MB 的房东照片原样下发 - 一份精选的可索引地区白名单,同时用于生成筛选下拉框
- Cloudflare Web Analytics,外加一个"Arrange Viewing"点击事件
明确排除在外
任何需要登录的页面、Seeker 账号体系、收藏与心愿单、地图视图、房贷计算器、中介房源、手工撰写的 地区落地页、评价或评分展示、英语以外的语言、GA4 或任何基于 cookie 的分析,以及任何 Bun 代理。
刻意不处理
- 缓存清除
- 下架一条房源后,其公开页面最多还会保持热缓存五分钟。不实现任何 purge 调用。
- slug 枚举接口
- sitemap 通过现有的分页 API 逐页翻取。
- 升级已有的三个 Astro 应用
- 在有人主动升级之前,它们继续留在 6。
- 把
area规范化 - 白名单在不改动切片 1 schema 的前提下控制住了影响范围。引用表是后续的升级路径。
- slug 重定向
- slug 不可变(ADR-0057),所以既没有历史表也没有 301 处理。正因如此,不做才是安全的。
缓存
| 响应 | 缓存头 |
|---|---|
| 房源详情 | public, s-maxage=300, stale-while-revalidate=3600, stale-if-error=86400 |
| 搜索结果 | public, s-maxage=60, stale-while-revalidate=300, stale-if-error=86400 |
| 降级或报错 | no-store |
搜索结果的窗口更短,因为筛选组合会让缓存条目成倍增长,而每一条重新生成的成本都很低。真正值得长期 保留的是详情页。
SEO 与索引规则
- 每条房源的标题与描述由房源数据拼装——例如"3-bedroom condo for rent in Petaling Jaya — RM2,000/month"——描述取自房东填写的文本并截断。
- Open Graph 标签携带封面照片,让 WhatsApp 分享能渲染成卡片。在这个市场,这是实实在在的流量来源。
- 每个详情页加上 JSON-LD
RealEstateListing。 sitemap.xml由在架房源生成,缓存一小时。robots.txt全部允许,并指向 sitemap。
切片 1 的公开 API 是分页的,且无法一次性取出全部在架 slug,因此 sitemap 路由会逐页翻取直到取尽, 并设一个硬上限。以当前的数据量而言,那就是一次请求。如果房源数量真的涨到几千条,升级路径是在 Go 侧提供一个专门的 slug 接口。
图片
- 在边缘压缩
lib/image.ts在 R2 公开照片 URL 之上拼出/cdn-cgi/image/width=…,format=auto,…。没有构建步骤、没有图片流水线、没有依赖。- 显式尺寸
- 每个
<img>都带width和height,取自新增的照片尺寸字段,让浏览器预留正确的位置,把 CLS 保持为零。 srcset- 一小组固定宽度,避免手机下载桌面尺寸的图片。
- 懒加载
- 除 LCP 图片外,其余一律
loading="lazy"——/上第一张卡片的照片,以及详情页的封面照片除外。对 LCP 图片做懒加载,恰恰会拖慢被测量的那一次绘制。 - 退路
- 如果该 zone 上
/cdn-cgi/image实际不可用(交付第 1 步验证):改为在切片 1 的上传环节限制图片尺寸,其余保持不变。
本设计对切片 1 的要求
| # | 要求 | 原因 |
|---|---|---|
| 1 | rental_property_photos 增加 width 和 height 字段,在上传时从图片头信息读取写入,并在公开响应中暴露 |
零 CLS 渲染。切片 1 在上传时本来就要读取文件以执行 5MB 和文件类型限制,所以那一刻尺寸信息已经在手上 |
| 2 | 公开搜索响应增加 areas facet——所有在架房源中出现过的去重地区,与当前筛选条件无关 |
筛选下拉框取白名单 ∪ 在架地区。搜索接口是分页的,第 1 页上的地区并不等于数据中的地区。在既有响应上加一个 facet 不产生额外请求,还能沿用现有缓存头 |
| 3 | 公开路径为 /api/public/rental/…,不带版本号 |
已在 ADR-0056 中钉住,两侧不会各走各的 |
| 4 | 按 ADR-0057 的 slug 规则:仅当 slug 为 null 时、在首次转为 live 时生成;构成为 {bedrooms}-bedroom-{property_type}-{area}-{suffix};始终带 4 字符后缀;slugify 后为空的部分丢弃;最坏情况下只保留后缀;永远不因 slug 而导致审核失败 |
/property/{slug} 正是 Google 索引的对象,也是人们粘贴到 WhatsApp 里的东西 |
Arrange Viewing 的交接
这是整个产品唯一的转化时刻,也是 Seeker 变成 Tenant 的地方。按定义每个 Seeker 都未登录, 所以这一次点击必然会撞上注册墙。
本应用这一侧
- 按钮文案保持为 "Arrange Viewing"。曾考虑过:"Sign up to arrange viewing"——已否决, 它等于在一个本该激发兴趣的页面上预先张贴阻力。点击事件才能告诉我们,这份阻力究竟有没有真的损害转化。
- 它链接到
https://panel.gremlin.my/tenant/book/{slug}——指向具体那条房源,而不是一个列表。
切片 2 必须补上的部分
-
一个
/tenant/book/{slug}路由切片 2 目前只有
/tenant/requests,那是一个列表——把一个匿名访客丢在那里,就等于弄丢了他专程而来的那套房子。 -
slug 要熬过注册往返
注册完成后,Seeker 必须回到那个确切的路由。共享同一个可注册域名,所以这属于同站状态,不是跨域问题。
-
一个"已不可用"状态
该路由在加载时解析 slug;当它已无法解析时,显示"This property is no longer available"并给出返回
rent.gremlin.my的链接——绝不能是裸的 404。这正是"不做缓存清除"这一取舍的可见症状;设计接受了这种滞后,而这里就是处理它的地方。
隐私边界与错误处理
| 场景 | 行为 |
|---|---|
| API 超时或不可达,页面已缓存 | Cloudflare 通过 stale-if-error 提供旧副本;Seeker 看到的是一个正常的站点 |
| API 超时或不可达,页面从未缓存 | "Temporarily unavailable"页面,HTTP 503,no-store |
| 未知 slug | 404 页面,带一个返回搜索的链接 |
| 房源已不在架 | 走同一条 404 路径——API 只返回在架房源 |
非法筛选值,例如 min_rent=abc | 忽略并视为未设置。绝不返回 500 |
| 页码超出范围 | 空状态,不是错误 |
| 无结果 | 朴素的空状态,提示放宽筛选条件 |
| Seeker 点击"Arrange Viewing",而该房源在过去 5 分钟内被下架 | 由 panel 一侧处理,不在这里。本应用按设计就无从知晓:它没有 purge |
度量
本设计中没有任何其他部分能告诉你它是否奏效,而缺了这一点,你就无法区分是房源供给的问题,还是租客 需求的问题。
- Cloudflare Web Analytics——一个 script 标签,免费,不使用 cookie,因此不产生 PDPA 同意义务,也不需要同意横幅。
- 在"Arrange Viewing"点击上加一个自定义事件,这才是真正关键的数字:它能区分"没人来"和"有人来但不愿注册",也决定了"先渲染预约表单、之后再要求注册"这个方案是否值得去做。
- GA4 按决策排除在外。它会引入这个站点本来没有的 cookie 同意义务。
如果 Cloudflare Web Analytics 实际上不支持自定义事件,退路是把这项缺失的度量记为已知缺口, 而不是为了一个数字引入更重的分析栈。
测试
rental-api.ts单元测试——查询串拼装、响应解析、超时行为和错误结构,针对一个 stub 服务。沿用既有的apps/quiz/src/middleware.test.ts先例。- 泄露测试——渲染一个房源详情页,断言 HTML 中不出现电话、邮箱、房东姓名或详细
address_line。 - 契约漂移测试——把
rental-api.ts加入切片 1 公开 golden fixture 的客户端清单,断言 client ⊆ Go(ADR-0052 模式)。 - 索引规则单元测试——针对判定
index还是noindex, follow的那个函数,覆盖:裸/、仅白名单地区、仅非白名单地区、地区加任意第二个筛选,以及page=2。这条规则在渲染结果中看不出来,很容易被悄悄改坏,正因如此才值得一个测试。 - 一条 Playwright 主流程——进入
/、按地区筛选、打开一条房源,确认"Arrange Viewing"指向panel.gremlin.my/tenant/book/{slug}。
刻意不做:视觉回归、Lighthouse CI、跨浏览器矩阵。
交付顺序
- 搭建
apps/rentalAstro 7 + Cloudflare 适配器,部署到
rent.gremlin.my。同时充当上述四合一探路。 - ADR-0056 与 ADR-0057
已完成——两份都写在实现之前,而不是实现过程中。
rental-api.ts及其单元测试基于 stub 构建。
- 搜索页
筛选表单、结果列表、空状态。
- 带
ClientRouter与 prefetch 的布局实现无刷新导航。
- 房源详情页
照片相册、泄露测试。
- SEO
标题、Open Graph、canonical 规则、对非白名单筛选加
noindex、JSON-LD、robots.txt、sitemap.xml。 - 缓存头与降级状态
- "Arrange Viewing"链接进入 panel
- Playwright 主流程,部署
待验证项与开放问题
本设计提出的全部 22 个问题,已在 2026-08-26 的一次拷问(grilling)中全部敲定,结论都已并入上面 各章节。剩下的不是问题,而是一份交付第 1 步需要验证的清单,每一项都已写明退路。
| 待验证 | 失败时的退路 |
|---|---|
Astro 7 中 ClientRouter 会拦截 GET 表单提交 | 用一个调用 navigate() 的小 Qwik island |
Cloudflare 遵守 stale-if-error | 未缓存请求维持原有的 503 页面 |
该 zone 上已启用 /cdn-cgi/image 转换 | 改为在切片 1 的上传环节限制图片尺寸 |
rent.gremlin.my 的自定义域名绑定与 DNS 记录能正常签发 | — |
| 两个 Astro 大版本给 Turbo 和共享类型带来多少摩擦 | shared-ui 只有两个 Astro 组件,且由使用方应用自行编译,因此暴露面看起来很小 |