展开目录
#微信小程序#AI编程#独立开发#实战#云开发#热搜聚合

我用 AI 手搓了一个「HN的榜单」小程序:23 个平台热榜、零服务器,从被判「信息资讯」驳回到申诉成功上线

三个开发日、15 次提交、1549 行代码。云函数中转绕开域名白名单,提审被微信以「服务内容涉及信息资讯」驳回,靠把工具属性论证成结构性事实申诉成功——现在微信搜「HN的榜单」就能用。含完整架构、六个真实坑与申诉方法论。

预计阅读 16 分钟

一句话总结

我用 AI 在三个开发日里手搓了一个微信小程序「HN的榜单」——一屏聚合 23 个平台的实时热榜,零服务器、1549 行代码。它提审时被微信判定「服务内容涉及信息资讯」直接驳回,我没有改功能,而是把「这是查询工具而非资讯服务」论证成一组可逐项核验的结构性事实,申诉成功,现在微信搜「HN的榜单」就能用。这篇文章把架构、六个坑和那份申诉逻辑完整写下来。


一、起因:我每天在八个 App 之间反复横跳

事情的起点特别朴素:我每天要看微博热搜、知乎热榜、36氪、IT之家、GitHub Trending、Hacker News、V2EX、Solidot。八个标签页,八次加载,八次被信息流截流——明明只想扫一眼标题,最后总要多花二十分钟。

我的网站 uuaihub.com 首页早就有一个「实时热点」看板,服务端抓取 23 个平台的公开榜单聚合成一张密集网格。桌面端体验很好,问题是——我刷热榜 90% 的时间在手机上,而手机上我不会专门去开浏览器输网址。

微信小程序是唯一符合直觉的答案:搜一下就能用,不用下载、不用登录、不占桌面。于是有了这个项目,小程序名字我起得很直白,就叫**「HN的榜单」**——Hacker News 的那个 HN。

二、成品是什么样

一句话:把 uuaihub 首页的热点看板,压缩到一块手机屏幕上。

  • 2 列密集网格,每张卡片一个平台,默认显示 6 条,点「查看全部」看完整榜单
  • 2 分钟自动刷新,下拉手动刷新,本地缓存兜底(断网也能看到上次的榜单)
  • 分类 tab:全部 / 综合 / 科技 / 财经 / 体育
  • 关键词搜索:跨全部平台搜标题,200ms 防抖
  • 卡片管理:上移下移、隐藏平台,偏好持久化到本地
  • 点条目看正文摘要:直接在小程序内读,读不到就引导复制链接去浏览器
  • 深色主题、无广告、无登录、无支付

真实数据(今天线上实测):上游 API 返回 HTTP 200 / 109,273 字节 / 23 个平台,从澎湃新闻的 20 条到 Hacker News 的 12 条,一次全量下发。

三、架构:三层,而中间那层是整个项目的命门

HN的榜单三层架构

小程序开发者都撞过这堵墙:wx.request 只能访问你在后台配置过的「request 合法域名」,而能配上去的域名必须已完成 ICP 备案。 我的 uuaihub 部署在 Vercel(海外节点),这个域名没做 ICP 备案,所以它进不了白名单,小程序端直连必然被拦。

先说清楚「备案」这件事,别被我上一句误导

这里有两个完全不同的备案,很容易被混为一谈:

是什么我的处理
小程序自身的 ICP 备案2023 年起微信要求,小程序上架前必须完成,跑不掉老老实实做了,这是上架前置条件
自有域名的 ICP 备案只有备案过的域名才能配进 request 合法域名 白名单没做,用云函数中转绕过

所以准确说法是:小程序该做的备案一步没省,云函数中转省掉的只是「给 uuaihub.com 这个海外域名单独跑一遍 ICP 备案 + 配白名单」这条支线。 云函数发出的请求走微信官方域名,不受小程序合法域名白名单约束——这是官方文档明确支持的正规做法,不是什么擦边操作。

于是链路变成这样:

小程序 pages/index
  → wx.cloud.callFunction({ name: 'trending' })      ← 微信官方域名,不受白名单约束
    → 云函数 cloudfunctions/trending(60s 内存缓存)
      → https://www.uuaihub.com/api/trending.json     ← 服务端抓取,无 CORS / 白名单限制
        → 23 个平台的公开榜单

云函数本体只有 76 行,而且刻意零依赖——只用 Node 原生 https,不引 axios、不依赖全局 fetch(云函数运行时版本不一定有):

exports.main = async (event = {}) => {
  // 正文抓取透传:event.url 存在时走 article 上游
  if (event && event.url) {
    try {
      return await getJson(`${ARTICLE_UPSTREAM}?url=${encodeURIComponent(event.url)}`);
    } catch (err) {
      return { ok: false, error: err.message };
    }
  }
  // 命中内存缓存直接返回
  if (memCache && Date.now() - memCache.t < CACHE_TTL) return memCache.data;
  try {
    const data = await getJson(UPSTREAM);
    memCache = { t: Date.now(), data };
    return data;
  } catch (err) {
    // 上游不可用:返回空结构(前端有本地缓存兜底),并短暂缓存避免雪崩
    const empty = { updated_at: Date.now(), sources: [] };
    memCache = { t: Date.now(), data: empty };
    return empty;
  }
};

三个细节值得单独说:

  1. 失败也写缓存。上游挂了返回空结构,同时把这个空结构缓存 60 秒——否则每个用户的每次刷新都会去捅一遍已经挂掉的上游,形成雪崩。
  2. 一个云函数干两件事。抓正文没有新建云函数,而是用 event.url 是否存在做分支。少一个函数就少一份部署配置、少一次冷启动预热。
  3. 手动跟 301/302。Node 原生 https.get 不自动跟随重定向,而 uuaihub.com 裸域名会 307 到 www。这行 if (res.statusCode >= 300 ...) getJson(res.headers.location) 是被真实报错逼出来的。

代码量分布也贴一下,好让「手搓一个小程序」这件事有个量感:

文件行数职责
miniprogram/pages/index/index.wxss646样式(深色主题 + 密集网格 + 弹窗)
miniprogram/pages/index/index.js567页面逻辑(刷新/缓存/详情/分享/管理)
miniprogram/pages/index/index.wxml182结构
cloudfunctions/trending/index.js76云函数中转
miniprogram/utils/filter.js57纯逻辑:排序/筛选/搜索
miniprogram/utils/format.js21热度与时间格式化
合计1549

四、三个开发日的真实节奏

三个开发日的时间线

从 git log 看得很清楚,15 次提交挤在三天里:

8 月 18 日 · 从 0 到可用(6 次提交) 云函数中转跑通、密集网格 + 本地缓存、分类筛选 + 搜索 + 回到顶部 + 卡片管理、手机端改单列、修百度热搜排名、点条目弹详情、经云函数透传抓正文摘要。第一天就是完整闭环。

8 月 25 日 · 上版前打磨(4 次提交) 图文混排渲染、登录墙内容优雅降级、排序策略重写。然后提审——被驳回。

8 月 26 日 · 申诉成功并继续提速(3 次提交) 写申诉材料、把界面措辞改成工具定性、提交申诉。当天申诉通过,小程序正式发布上线。 同一天顺手把正文抓取改成「Firecrawl 与裸抓并行竞速」,失败路径从 11~14 秒降到约 9 秒,又加了正文本地缓存和超时重试。

值得说的是AI 在这三天里承担的角色不是「写代码」,而是「写完并解释清楚为什么」。云函数文件头那段注释——「为什么需要它:小程序 wx.request 只允许访问已备案域名……这是个人开发者的标准方案」——是让 AI 把决策理由留在代码里。三天后我回头看排序逻辑时,这类注释救了我至少两次。

五、六个真实的坑

六个真实踩坑

坑 1:request 合法域名白名单(架构级)

已在上一节说清:能配进白名单的域名必须已 ICP 备案 → 走云函数服务端转发。补一个易漏点:如果云开发有多个环境,wx.cloud.init() 必须显式填 env,单环境才能省略。多环境不填会调到你以为不存在的那个环境上,报错还很不直观。

坑 2:云函数默认 3 秒超时

抓一篇正文动辄 10 秒以上,默认 3 秒必然全线超时。修法是三层预算对齐:

云函数 config.json  timeout: 20s
  ← 上游服务端预算   15s(后收紧到 9s)
  ← 前端兜底         ARTICLE_TIMEOUT_MS = 18000

前端 18 秒卡在中间,是为了「服务端预算 + 网络余量」——比云函数的 20 秒早一点放弃,用户能拿到明确的失败提示,而不是干等到云函数那头断掉。

顺带一条更贵的教训:重试必须有总预算。 我一度给 Firecrawl 加了「429/5xx/超时都退避重试一次」,结果单次 20s + 无条件重试 = 最坏 41.5 秒,比云函数超时还长——重试把一个本来会快速失败降级的请求,变成了必然超时。规则:只对限流(429)重试,超时和 5xx 直接降级;预算必须小于调用链上最紧的那一层。

坑 3:个人主体没有 web-view

web-view 组件对个人主体开发者关闭,所以「点条目内嵌打开原网页」这条路走不通。绕法是两段式:先在小程序内展示正文摘要,展示不了就给「复制链接看原文」按钮。 这个妥协后来成了申诉时的重要论据(下一节细说)。

坑 4:百度热搜的排名从 0 开始

上游给百度热搜的 rank 是 0-based,小程序里直接渲染就出现了「第 0 名」。归一化 +1 修掉之后,紧接着又冒出第二个 bug:缓存里存的是已经处理过的数据,二次读取时又被处理了一遍,热度值丢了。 教训很朴素——缓存只存原始数据,所有派生计算放在渲染前做

坑 5:排序改了不生效

我在服务端调整了平台的默认顺序,结果自己手机上完全没变。原因是本地 prefs.order 一直存着一份完整的顺序,无条件覆盖服务端下发的顺序。

修法是把「空」赋予语义:

// order 为空表示「跟随服务端下发顺序」—— 直接原样返回,不依赖 Array.sort 的稳定性
function sortByOrder(sources, order) {
  if (!order || !order.length) return sources.slice();
  ...
}

默认不写 order,用户第一次手动上移下移时才把当前顺序落成 order 并标记 customized 再配一个 PREFS_VERSION = 3,服务端调整默认排序后自增一次,让老用户存在本地的旧顺序失效一次。这是典型的「客户端持久化偏好 vs 服务端策略更新」冲突,任何带本地偏好的应用早晚都会遇上。

坑 6:抓不到正文的那些平台

知乎、微博这类平台,抓回来的经常不是正文而是登录墙或反爬页面。这里有一条我给自己划的红线:不做任何形式的破解或代理。 判定为登录墙/聚合页时,直接显示提示:「该平台通常需登录或为聚合页,请用『复制链接』到浏览器看原文」。

上游那边则用 Firecrawl(172k ⭐,匿名即可调用)处理反爬和 JS 渲染,失败时降级到自实现的裸抓。后来改成两者并行竞速——总耗时从 sum 变成 max,GitHub 那条从「14.3 秒抓到 0 字」变成「0.9 秒抓到 11398 字」,最慢的一条也从 14.3 秒压到 8.6 秒。

这里的排查方法比结论更有用:不要盯着「偶发超时」这个现象猜,按「耗时 + engine + 正文字数」三列打张表,一眼就能看出慢的是成功路径还是失败路径。我当时看到的是——慢的三条全是 engine=native 且正文 0 字,也就是用户白等 14 秒换来一句「抓不到」。

六、提审被驳回:判定「服务内容涉及信息资讯」

2026-08-25 提审,驳回理由:

存在个人主体小程序未开放的服务类目(《常见拒绝情形 2.1》),判定「服务内容涉及信息资讯」。

微信给的修改指引是「你的类目选正确了吗」。我一开始真去翻类目表了,翻完才发现这句指引有误导性——

官方《小程序开放的服务类目》里,「资讯」与「时政信息」只存在于非个人主体分类下。个人主体压根没有这个类目可选。 换句话说:不是我选错了,是我需要的那个选项对我关闭。审核员那句「建议申请企业主体」才是实质结论。

相关条款:规范 5.7.3 明确「小程序发布展示相关内容,未申请通过相关类目,包括但不限于:发布展示信息资讯、文娱、社交等信息」。而社区共识(GitHub 上流传的《微信小程序过审指南》)说得更直白:个人主体基本上只能做信息查询类小程序。

七、申诉成功的核心:把「工具属性」论证成结构性事实

资讯服务与查询工具的构成要件对比

想清楚一件事之后,申诉思路就定了:审核不接受「我承诺不做资讯」,只接受「我在结构上做不到资讯」。

于是申诉材料围绕六条构成要件逐项对账:

资讯服务的构成要件「HN的榜单」的实际形态
编辑团队 / 内容采编无。没有后台编辑、没有投稿、没有审稿
内容生产或摘编无。不撰写、不改写、不摘编、不配图
人工推荐位 / 编辑排序无。排序完全沿用各站点自身榜单顺序
评论 / 转发 / 社区互动无。零 UGC 入口
内容库与归档无。实时查询,不建库、不归档
订阅与推送分发无。无订阅、无 push

以及一条只能靠客观对比证明的论点:所展示的仅为各站点对外公开的榜单标题,与用未登录浏览器直接访问所见完全一致,未使用任何登录态、未绕过任何访问限制。

坑 3 里那个「无 web-view 只能复制链接」的妥协,在这里变成了正面论据:最终阅读行为始终发生在原站点,本小程序不承载阅读闭环、不替代任何资讯客户端。

最有说服力的不是形容词,是可核验的清单

真正让论证站得住的,是把「无内容生产能力」写成审核员能逐条点开核对的事实。我实测统计后写进材料的是这份:

【页面结构】共 1 个页面 pages/index/index。无子页面、无 web-view、无外部跳转。

【本小程序不具备的能力(可逐项核验)】
- 无文本输入框(除本地搜索)、无 textarea、无表单提交
- 无上传接口(无 wx.uploadFile / wx.chooseImage / wx.chooseMedia)
- 无评论、无点赞、无收藏、无社区、无任何 UGC 入口
- 无后台编辑、无人工推荐位、无内容库、无归档、无推送订阅
- 无广告、无支付、无会员

【类目对应】「工具-信息查询」对应首页,进入即为查询页,无隐藏、无多次跳转

这些都是 grep 一遍代码就能得到的数字(上传/表单接口计数为 0,页面数为 1),审核员照单核对全部对得上——比任何形容词都有力

附带发现:截图千万别自己递刀子

这条经验我觉得比申诉文本本身更值钱。

审核员判定「信息资讯」的直接依据,就是你提交的首页截图。如果你截的那张图上满屏时政、国际冲突、社会事件的标题——你等于亲手替对方举证。

正确做法:截图时切到「科技」分类 tab,或者向下滚到 GitHub Trending / Hacker News / V2EX / Solidot 这几张卡片再截。最推荐的一张是点开 GitHub Trending 卡片的「查看全部」弹窗——满屏 owner / repo 格式的仓库名,几乎不可能被看成资讯,而且这就是真实功能的一部分,不存在任何伪造。

反过来,有一份材料我明确没有提交:docs/promotion.md 里的宣传文案。那是面向传播写的,「聚合 22 平台」「全网热搜」资讯属性极强,递上去等于坐实对方的判断。

结果

2026-08-26 申诉通过,小程序正式发布上线,微信里搜「HN的榜单」就能找到。 功能一行没砍:23 个平台全在,正文摘要也在。改的只有界面措辞(把定性说清楚)和申诉材料的组织方式。

八、当时准备的四条路(最终走了 E)

四条合规路径对比

被驳回的当天我没有立刻申诉,而是先把情绪摘掉、认真列了一遍可选项——这一步比申诉本身更重要,因为它决定了申诉失败也不会卡死

方案做什么过审概率工作量代价
A. 收缩为技术/开源榜单只留 GitHub Trending、Hacker News、V2EX、Solidot、掘金、Linux.do、Freebuf;砍掉全部新闻源失去新闻类来源
B. 只砍正文抓取保留全部平台,仅显示标题 + 复制链接审核看首页截图就判定,砍正文救不了
C. 转个体工商户 / 企业主体办执照 → 企业主体 → 申请资讯类目执照 + 认证 300 元/年,且若含时政可能被要求《互联网新闻信息服务许可证》,个体户拿不到
D. 不上架,自用保持体验版长期使用(成员上限 15 人)不能公开、不能被搜索
E. 申诉 + 界面工具化提交申诉材料,同步把界面措辞改为工具定性当时判断偏低极小失败后仍需回到 A / C / D

最终 E 成功,一次通过。 事后看,我当时对 E 的成功率估低了——但决策顺序仍然是对的:E 的成本近乎零,先试 E 再退 A,期望收益最高。如果先动手砍数据源(方案 A),我会白砍掉一半功能。

还有一条我明确写进文档、也真的没做的红线:

绝对不能做:让上架版本只显示技术源、私下再用开关放出新闻源。这违反规范 5.8.3「利用隐藏小程序的测试内容等方式绕过审核」,会直接封号。

九、关于「用 AI 手搓」,五条我真正用上的方法

这一节是给同样想手搓点东西的人看的。三天做完一个小程序,AI 帮上忙的地方并不在「敲得快」。

1. 先让 AI 做方案对比,再让它写代码。 「给域名做 ICP 备案 vs 云函数中转」这个决策,我是先让 AI 把两条路的成本、时间、限制列成表格,拍定之后才动手。架构错了写得再快也是白写——被驳回后列那张四条路的表,也是同一个动作。

2. 把纯逻辑抽成不依赖 wx 的模块。 utils/filter.js 里的排序、筛选、搜索完全不碰 wx 全局对象,所以可以直接 node 跑测试,不用开开发者工具。文件头那行注释写的就是这个用意:「独立成模块以便本地测试(不依赖 wx 全局)」。AI 写业务逻辑很快,但它写的逻辑必须能被独立验证,否则你只能靠肉眼在真机上找 bug。

3. 每次改动都要有一条真实链路的验证命令。 这个项目的验证只有两行:

node --check miniprogram/pages/index/index.js cloudfunctions/trending/index.js
node -e "require('./cloudfunctions/trending/index.js').main().then(r=>console.log(r.sources.length))"
# 真实链路:536ms 拉到 23 个平台,二次调用 0ms 缓存命中

第二行直接把云函数当普通 Node 模块调用,真实打上游、真实验证缓存。「AI 说改好了」不算改好,命令输出才算。

4. 让 AI 把「为什么」写进注释。 PREFS_VERSION = 3 旁边那句「服务端调整默认排序后自增,让老用户的旧顺序失效一次」,就是典型例子。三天后我自己都会忘掉这个魔法数字的用意,注释是唯一的记忆载体。

5. 规则问题也交给 AI 查证,但要求它给出条款号。 类目红线这一整套结论——个人主体没有资讯类目、规范 5.7.3、5.8.3、常见拒绝情形 2.1——都是逐条查证官方文档得来的,不是凭印象。让 AI 给条款号,这样每一条结论你都能自己复核。 申诉这种一次性机会,靠「大概是这样」是不敢提交的。

十、总结

  • 技术上,这个项目是成立的。 三个开发日、15 次提交、1549 行代码、零服务器成本。云函数中转干净地解决了域名白名单问题,23 个平台的实时聚合稳定跑着,正文抓取从 14 秒优化到 9 秒以内。
  • 两种「备案」要分清。 小程序自身的 ICP 备案该做就做、跑不掉;云函数中转省掉的只是「给自有海外域名单独备案 + 配合法域名」这条支线。
  • 个人主体的类目红线是真实存在的,但不等于死路。 「资讯」类目对个人主体确实关闭,可我最终一行功能没砍就过审了——定性说清楚比砍功能有效。
  • 审核看的是截图,不是你的自我描述。 提审前先想清楚:审核员打开的第一张图,会把你归到哪一类?截图切到「科技」分类这个动作,可能比写 800 字申诉更管用。
  • 「结构上做不到」比「承诺不会做」有力得多。 无编辑后台、无 UGC 入口、无上传接口(grep 计数为 0)、单页面无跳转——这些都是审核员能一秒核对的事实,也是这次申诉真正的支点。
  • 被驳回时先列全部选项,再动手。 我先把 A/C/D/E 四条路的概率、工作量、代价摊在一张表上,才选了成本最低的 E。如果当场就去砍数据源,那半天工全白费。
  • AI 手搓的关键不在生成速度,在验证闭环。 先定架构、逻辑可独立测试、每次改动有一条真实链路的验证命令、决策理由写进注释、规则结论必须带条款号。

项目现状:已发布上线,微信搜索「HN的榜单」即可使用。23 个平台、每卡 6 条、2 分钟刷新,免费无广告。

相关阅读:本站首页的实时热点看板就是这个小程序的数据源头,桌面端一屏可看 23 个平台的完整榜单,无需任何客户端。

Related

相关文章

延伸阅读

查看全部 →