☰
基于Flutter的e621/e926客户端开发:API认证与图片加载避坑
2026/9/29 19:39:25 网站建设 项目流程

简介:这是一款基于 Flutter/Dart 构建的移动端应用源码,专门用于浏览 e621 与 e926 内容,为关注此类社区的移动用户提供搜索、浏览、评论、修改、上传下载、收藏与标签追踪等完整能力。面向希望学习 Flutter 跨平台开发的开发者,尤其适合对第三方 API 客户端、多媒体解析与自定义主题有一定进阶需求的读者。压缩包共 185 个文件,约 29.59MB,核心代码以 108 个 dart 文件为主,涵盖 API 客户端、DText 解析、帖子详情与设置等模块;32 个 png、7 个 jpg 与 2 个 gif 构成界面与预览资源,其余为 Android/iOS 工程配置(gradle、plist、storyboard)及项目元数据,结构清晰,便于按模块阅读。已有 3124 人浏览/学习,下载后可获得完整工程源码,既可直接编译运行至 Android/iOS 设备,也可重点研究 e621 API 封装、DText 富文本解析、视频播放、本地黑名单与多主题切换等实现,对理解 Flutter 实际商业级应用组织方式有较好参考价值。

1. e1547 是给 e621 + e926 做的移动端壳子:先分清镜像站再动手

e1547 这个编号看起来像竞赛题号或者仓库代号,但落到实操,它讲的是把 e621 和 e926 这两个站点的浏览体验搬进手机。e621 是 Danbooru 系图站,e926 是它的 SFW 镜像,内容同源,但两个站的域名、分级规则和 API 策略完全不同。做这个移动应用,核心不是 UI 多漂亮,而是把两套 host、UA 鉴权、rating 分级和 CDN 校验摸透,否则列表页能刷出来,点进大图就 403,收藏帖子还会 401。

这个方向很适合拿来练移动应用开发或参加技能大赛:它是标准的“不复杂但极易翻车”的工程,逼你处理真实 API、鉴权、内容合规和限速,而不是停在写死数据的 demo。我按自己做过的一套方案往下拆,把选型和踩坑摊开讲,走完你也能做出一个可上架的客户端。

2. 把 e621/e926 的 API 与认证摸清:Host、User-Agent 和限速档位

2.1 两个站点共用同一套底层,但 Host 和 Rating 边界不能混

e621 和 e926 在架构上是一对“主站 + 内容过滤镜像”。底层帖子库、标签体系、收藏关系都共享,但对外提供的 API Host 完全不同:api.e621.net是主接口,api.e926.net是 SFW 镜像接口。移动端如果只做一个适配,最省事的做法不是把两个站都塞进同一个 Host 配置,而是把 baseUrl 做成可切换的常量,并且让请求层强制绑定当前站点。

为什么要分开?两个站在三个维度上不一致。第一是认证上下文,e621 的账号 token 到了 e926 不一定被认可,确切说是两个站点对同一账号的 API Key 授权状态未必同步;第二是 rating 边界,e621 会返回 safe、questionable、explicit 三档内容,e926 只返回 safe;第三是 CDN 地址,图片资源分别挂在static1.e621.net和static1.e926.net下,URL 不能互相指。

站点API Host图片 CDN返回内容评级
e621api.e621.netstatic1.e621.netsafe / questionable / explicit
e926api.e926.netstatic1.e926.net仅 safe

做客户端的时候,我习惯在工程里先定义两个常量组:

class SiteConfig { final String apiHost; final String cdnHost; final List<String> allowedRatings; const SiteConfig({required this.apiHost, required this.cdnHost, required this.allowedRatings}); static const e621 = SiteConfig( apiHost: 'https://api.e621.net', cdnHost: 'https://static1.e621.net', allowedRatings: ['s', 'q', 'e'], ); static const e926 = SiteConfig( apiHost: 'https://api.e926.net', cdnHost: 'https://static1.e926.net', allowedRatings: ['s'], ); }

这段代码把站点差异约束在一个对象里,后续所有请求都从当前 SiteConfig 取值。allowedRatings在解析帖子时用来做第一层过滤,既能防止 e926 模式下意外展示 q/e 内容,也能在 e621 模式下按用户偏好跳过某个评级。把配置集中写的另一个好处是:如果哪天 e621 换了 CDN 域名,只改这一处就能全局生效。两个站点的搜索词、收藏列表、热门榜都可以走同一套分页逻辑,唯独 Host 不能混用。

2.2 请求头与认证:User-Agent 是最低门槛,API Key 比 OAuth 更省事

e621 和 e926 对请求有一个硬性要求:请求头必须带合法的 User-Agent,格式类似应用名/版本 (by 你的e621用户名)。不带 UA、或者 UA 长得像普通浏览器,接口会拒绝甚至直接断连。这个校验对 API 和图片 CDN 同时生效,所以不要在请求层忽略它。服务端之所以强制校验,是因为这类社区站点常年被爬虫骚扰,靠 UA 区分正常客户端和批量脚本是最基础的一层。

认证方式有两种。OAuth 2.0 适合做完整账号功能,比如收藏同步、自定义黑名单、私信;但如果你的应用只是浏览加搜索,一份 API Key 就够了。API Key 在账号设置页可以生成,请求时放在 Authorization 头,用 Basic 方式把用户名:api_key做 Base64 编码即可。我一般在 Dio 初始化时用拦截器统一处理这两件事:

import 'package:dio/dio.dart'; import 'package:shared_preferences/shared_preferences.dart'; Dio buildDio(SiteConfig site) { final dio = Dio(BaseOptions( baseUrl: site.apiHost, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 20), )); dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) async { final prefs = await SharedPreferences.getInstance(); final username = prefs.getString('username') ?? 'anonymous'; final apiKey = prefs.getString('api_key') ?? ''; options.headers['User-Agent'] = 'e1547/0.1.0 (by $username on e621)'; if (apiKey.isNotEmpty) { final raw = '$username:$apiKey'; options.headers['Authorization'] = 'Basic ${base64Encode(utf8.encode(raw))}'; } handler.next(options); })); return dio; }

这段代码做了三件事:统一拼接合法 UA;有 API Key 时自动加认证头;把超时时间调到移动网络可用的范围。connectTimeout设 10 秒是因为弱网环境下信号切换频繁,太短会误报超时;receiveTimeout设 20 秒是因为查大标签集合时服务端响应慢,但这个保护只针对单次请求,不保护整体数据量。认证失败时,服务端通常返回 401 表示未认证,403 表示 UA 或权限被拒,排查方向完全不同,日志里要把状态码和响应体一起记下来。

提示:把 API Key 放进 SharedPreferences 只是开发期方案。正式打包时建议改用系统级安全存储,Android 用 EncryptedSharedPreferences,iOS 用 Keychain,避免明文 key 随备份泄露。

2.3 分页与限速:page 参数别乱用,limit 最高 320

帖子列表接口是/posts.json,最关键的参数是tags、limit和分页。limit最大能开到 320,传超过 320 不会报错但只会给上限,所以我一般直接用 320 减少请求次数。分页有两种方式:老式的整数页码page=1,以及基于游标的page=b帖子id。整数页码在帖子总数变化时会重复或漏帖,收藏夹和热门榜这类动态列表要避开它。

我的请求封装长这样:

Future<List<dynamic>> fetchPosts(SiteConfig site, { String tags = '', String? pageCursor, int limit = 320, }) async { final dio = buildDio(site); final res = await dio.get('/posts.json', queryParameters: { 'tags': tags, 'limit': limit, if (pageCursor != null) 'page': pageCursor else 'page': 1, }); return (res.data as Map)['posts'] as List<dynamic>; }

代码里pageCursor优先于整数页码。第一页不传游标,服务端会在响应 meta 里给出下一页游标,后续请求直接把上一页拿到的游标原样塞回去。这种做法比“当前页码加 1”稳定得多,因为服务端列表按 id 排序,数据一直在变,整数页码计算的是偏移量,游标计算的是“从哪个 id 继续”,后者不会重复也不会跳过。

限速方面别背网上流传的硬数字,直接看响应头里和x-rate-limit相关的字段,服务端会告诉你当前剩余次数和重置时间。客户端要做的是把剩余次数打日志,当剩余低于阈值时把并发预加载改成串行。很多限速翻车不是一次请求触发的,是列表页一次性并发 20 张图片,额度瞬间打满。

3. 移动端数据层选型与搜索:把 e621 的标签语法搬进 App

3.1 为什么用 Flutter + Dio:移动应用开发的参赛与上架平衡

技术选型没有绝对答案,我做完一轮比较后选了 Flutter。原因有三:单套代码覆盖 Android 和 iOS,两个应用商店的适配工作量减半;Dio 拦截器体系正好匹配 e621 这种需要在请求层统一加 UA/认证的场景;图片缓存用 cached_network_image 可以带自定义请求头,解决 CDN 校验问题。如果你在准备移动应用开发技能大赛,Flutter 也是比较稳妥的选择,赛题常用跨端框架,调试时不用在两台真机间反复传导。

原生双端不是不行,但同样的代码要在 Kotlin 和 Swift 里各写一遍请求拦截、OAuth 回调和内容过滤,等于把踩坑次数翻倍。对于 e1547 这种“API 细节比 UI 复杂”的项目,少一套语言的成本优势很明显。另一个加分项是 Flutter 的调试体验:DevTools 里直接看 Dio 请求耗时和响应头,比在原生里打断点看拦截器方便很多。

一个容易忽略的点:Dio 默认会把 query 参数按 Map 的遍历顺序拼进 URL,而 e621 的搜索不依赖标签顺序,所以顺序无关。但千万不要自己手工拼 URL 字符串。把tags传给 Dio 的queryParameters后,它会帮你处理空格和中文标签的 URL 编码;如果手拼tags=...,漏掉一个%20就可能整页报错。重试策略我也习惯在拦截器里做,对 429 和 5xx 各重试一次,间隔指数退避,但要给重试次数设上限,避免弱网下无限循环。

3.2 搜索语法解析:空格是 AND,OR 是大写,排除用减号

e621 的搜索框语法是通用 Danbooru 标签语法,不做解析直接透传也能跑,但移动端用户的输入习惯会搞坏它:输入法自动把中文逗号、英文逗号混在一起,服务端标签解析器不认这些符号。所以必须自己做一层清洗。

我写的解析函数处理四件事:去掉多余空白;中文逗号转空格;保持-tag前缀符号不被拆分;把OR保留为大写逻辑符。

List<String> tokenizeE621(String input) { final cleaned = input .replaceAll(',', ' ') .replaceAll(',', ' ') .trim(); return cleaned .split(RegExp(r'\s+')) .where((t) => t.isNotEmpty) .toList(); } String formatE621Query(String userInput) { final tokens = tokenizeE621(userInput); final List<String> parts = []; String currentOr = ''; for (final t in tokens) { if (t == 'OR') { currentOr += ' OR '; } else if (t.startsWith('-')) { if (currentOr.isNotEmpty) { parts.add('(${currentOr.trim()})'); currentOr = ''; } parts.add(t); } else { currentOr += '$t '; } } if (currentOr.isNotEmpty) parts.add('(${currentOr.trim()})'); return parts.join(' '); }

逻辑说明:输入fox mage OR wolf -dog会被拆成(fox) (mage OR wolf) (-dog),对应服务端语义是“第一组必须满足,第二组二选一,第三组必须排除”。为什么要加括号?因为服务端把空格解析为 AND,如果不加括号,mage OR wolf -dog会被理解成 “mage 与 wolf 与 -dog” 的三元组,OR 失效。这个坑我是在搜索“cat OR tiger”时发现的,不加括号结果集明显变少。

参数方面,用户输入里可能混进rating:s、score:>=50这类元标签,解析函数不破坏它们,原样传给服务端。这样高级用户能继续写完整语法,普通用户又能得到键盘容错。另外要注意:e621 支持中文标签,如果你要面向中文用户,不要把标签强制转成 ASCII,原样透传就好。

3.3 图片加载必须带 UA:CDN 校验造成的 403 血泪经验

这是做 e621/e926 客户端最典型的翻车点:列表接口正常返回 JSON,图片却全挂,控制台一片 403。原因在于图片请求不带 API 层那套 User-Agent,CDN 直接拒绝。Flutter 自带Image.network不让你传请求头,所以必须换加载方式。

我用 cached_network_image 在全局统一处理:

import 'package:cached_network_image/cached_network_image.dart'; CachedNetworkImageProvider buildCdnImage(String url, SiteConfig site) { return CachedNetworkImageProvider( url.replaceFirst('static1.e621.net', site.cdnHost), headers: { 'User-Agent': 'e1547/0.1.0 (by anonymous on e621)', 'Accept': 'image/webp,image/*,*/*;q=0.8', }, ); }

headers 里的 UA 要与 API 层保持一致,换个 UA 一样被拒。Accept头不是必须的,加上后 CDN 会优先回 WebP,流量更省。注意url.replaceFirst这行:如果列表接口返回的图片地址来自 e621 CDN,而当前站点是 e926,必须把域名替换成当前站点的 cdnHost,否则跨站取图会因站点评级策略不一致偶尔出问题。

另一个参数是缓存 key:e621 图片 URL 是稳定的 hash 路径,不需要额外处理。但如果你给图片加了“强制原图”的逻辑,注意统一 key 拼接规则,别让同一张图在内存里存两份。清除缓存时也要谨慎,只清应用自身缓存目录,别动系统图片缓存。

4. 内容分级与安全过滤:e926 的 SFW 模式如何落到产品设计

4.1 rating 三档与镜像站策略

Danbooru 系帖子自带rating字段,取值是单字符:s表示 safe,q表示 questionable,e表示 explicit。e621 三档都返回,e926 只保留s。这意味着你不需要额外做图片识别,只需要信任服务端字段,并且在请求与展示两层做校验。

请求层要注意:搜索参数里写rating:e,在 e926 模式下大概率得到空列表,有时候接口会直接拒绝组合。我在请求封装里对 query 做了约束——检查当前站点是否允许该 rating,不允许就在本地拦截并提示“当前站点不支持该过滤条件”,而不是把请求发出去等服务端错误。这个拦截能省下很多无谓的限速消耗。

展示层的过滤要点是“不只过滤列表,还要过滤详情页”。搜索结果里可能混入少量带敏感标签但 rating 为 s 的帖子,比如含成人主题讨论的漫画页,光看 rating 不够。我习惯把 rating 和标签黑名单同时作为过滤条件,只有评级异常或命中黑名单才拦截。questionable这个分级的尺度很模糊,很多内容比 explicit 更擦边,所以不能只在 e621 模式下默认放行,SFW 开关关闭前要有心理预期。

4.2 客户端内容过滤:默认 SFW 开关与标签黑名单

产品设计上,移动应用商店对成人内容审核普遍敏感。一般做法是应用默认 SFW 模式,只显示 safe 内容;想浏览其他评级,必须先到设置里手动开启“高级内容”开关,并做二次确认。这不是隐蔽手段,而是内容分级应用的合规惯例——开关默认关闭,用户在知情前提下选择。

过滤逻辑放在数据层而不是 UI 层,这样列表、详情、收藏页都能复用:

class ContentFilter { final bool sfwOnly; final Set<String> blacklist; const ContentFilter({this.sfwOnly = true, this.blacklist = const {}}); bool allow(Map<String, dynamic> post) { final rating = post['rating'] as String? ?? 's'; if (sfwOnly && rating != 's') return false; final tagMap = post['tags'] as Map<String, dynamic>? ?? const {}; final allTags = <String>{ ...?tagMap['general'] as List<String>?, ...?tagMap['species'] as List<String>?, ...?tagMap['character'] as List<String>?, ...?tagMap['artist'] as List<String>?, }; return blacklist.every((b) => !allTags.contains(b)); } }

这段代码把“评级”和“黑名单”分开处理。sfwOnly拦截评级不等于 safe 的帖子,blacklist拦截包含指定标签的帖子。注意标签在 API 返回里是按类型分组的,tagMap['general']、tagMap['character']等字段都要展开,只查 general 会漏掉角色标签。黑名单在设置页用多行输入框维护,每行一个标签,存到本地偏好。

过滤顺序也有讲究:先判断 rating 再判断标签,因为 rating 是单字段判断,成本低;标签判断要展开数组,成本高。命中黑名单的帖子在列表里直接不渲染,而不是先渲染再隐藏。如果你用无限滚动列表,过滤掉帖子后列表长度会变化,分页游标要基于过滤前的帖子 id 计算,否则会跳过后续内容。

4.3 账号登录与收藏同步:OAuth 回调在移动端的落地

如果应用只做浏览,API Key 就够。一旦做收藏同步、踩/赞、评论自己的列表,就建议用 OAuth 2.0。e621 的 OAuth 流程是标准授权码模式:应用先拼一个授权 URL,用户跳浏览器登录确认,服务端回调拿到 code,再用 code 换 token。移动端关键在回调地址:Android 要配置 App Links 或自定义 scheme,iOS 要用 Universal Links,不然回调后 App 无法唤起。

换取 token 的请求一般是POST /oauth/token,参数包括grant_type=authorization_code、code、client_id、client_secret。拿到access_token后,后续请求在 Authorization 头用 Bearer 方式带,和 Basic API Key 二选一,不能同时使用,否则服务端优先认其中一个导致另一个失效。

Future<void> exchangeCode(String code) async { final res = await dio.post('/oauth/token', data: { 'grant_type': 'authorization_code', 'code': code, 'client_id': appClientId, 'client_secret': appClientSecret, }); final accessToken = res.data['access_token'] as String; final refreshToken = res.data['refresh_token'] as String?; // 存进安全存储,后续拦截器改用 Bearer 头 }

OAuth 有两个坑。第一,client_secret放在移动包里,抓包能翻出来,所以不能把它当作机密;服务端要配合“重定向 URI 白名单”限制滥用。第二,token 过期后的刷新流程,很多实现只存了 access_token 忘了 refresh_token,导致用户过两天就要重新登录。我一般把两个 token 一起存,401 时自动用 refresh_token 换新 access_token,再重放一次原请求。

5. 避坑:e621 + e926 双端适配里最常见的 5 个翻车点

5.1 现象:帖子列表正常,点进详情图片全部 403

原因:API 层加了统一 UA,但图片加载组件用的是 Flutter 默认 Image,请求头不带 UA,被 CDN 识别为异常客户端拒绝。解决:全局替换为 cached_network_image,并在 headers 里带上与 API 相同的 UA;详情页的轮播图也走同一个 Provider,不能只改列表页。排查时先开抓包看失败请求的 request headers,如果里面没有User-Agent字段,问题就锁定在图片加载层。403 响应里一般还带有 CDN 的特征字段,可以作为二次确认。

5.2 现象:收藏夹翻页出现重复帖子,翻到最后死循环

原因:用整数页码做收藏夹分页,但收藏列表会随操作变化,偏移量分页在数据变化时不稳定。解决:改用游标分页。收藏夹接口返回的page字段通常能直接作为下一页参数,客户端只需要把“下一页”传给服务端,而不是自己计算页数。我在封装里对列表页统一用pageCursor逻辑,整数页码只保留给普通搜索。测试翻页时,用一个经常变化的标签词连续翻 5 页,对比帖子 id 是否出现重复,这是最直接的验证方式。

5.3 现象:搜索框输入中文或逗号后接口报错,或结果为空

原因:输入法把逗号转成中文标点,服务端标签解析器不认识;另一些情况是 URL 编码不完整,空格没有转成%20。解决:请求前做 tokenizer,把中文逗号替换成空格;tags 参数全部交给 Dio 的 queryParameters 编码,不手拼字符串。搜索rating:e在 e926 返回空或报错也是同一类问题,代码里在请求前根据 SiteConfig.allowedRatings 做本地拦截。这类问题最容易在线上被用户骂,因为输入法行为没法控制,只能靠解析层兜底。

5.4 现象:应用商店审核被拒,理由是“不适内容”

原因:应用里保留了 explicit 内容的浏览入口,审核员用默认配置点开看到非安全内容。解决:发布版的默认 sfwOnly 为 true,高级内容开关默认关闭,并在应用商店的年龄分级里如实勾选。审核角度要的是“默认内容安全”,不是“完全不允许”,开关设计成默认关、二次确认,同时不要开放匿名直达rating:e的入口,基本能过。这里有个细节:审核员会在未登录状态下测试,所以匿名模式的过滤策略要和登录后一致,不能只在账号资料里做偏好。

5.5 现象:热门榜刷新时请求瞬间打满,接口持续 429

原因:列表和图片并发请求同时发出,限速额度被一波消耗完。解决:做串行请求队列,图片预加载设置并发上限,比如同时最多 4 张;读取响应头里的限速剩余字段,小于阈值时暂停加载。另一个常用后悔药是缓存:热门榜一小时内的数据变化不大,本地缓存加短 TTL 能避开峰值限速。排查时在请求日志里看 429 出现的时间分布,如果集中在同一秒,就是并发没控住;如果分散,可能是 UA 或 key 被服务端加了惩罚权重,需要检查是否无意中带了两个认证头。

6. 进阶玩法:用 WebSocket 做标签订阅实时推送

列表、搜索、收藏只是客户端的基础盘。e621 服务端有 WebSocket 端点,监听wss://e621.net/ws可以收到新帖通知。做标签订阅推送是我验证这套工程能力最喜欢的方式:用户维护一组关注标签,服务端一有新帖,App 立刻弹本地通知,完全不用轮询。

import 'package:web_socket_channel/web_socket_channel.dart'; void watchNewPosts(Set<String> watchTags, void Function(Map<String, dynamic>) onHit) { final ws = WebSocketChannel.connect(Uri.parse('wss://e621.net/ws')); ws.stream.listen((raw) { final msg = jsonDecode(raw as String); if (msg['type'] != 'post:create') return; final post = msg['data']['post'] as Map<String, dynamic>; final tagMap = post['tags'] as Map<String, dynamic>? ?? const {}; final tags = { ...?tagMap['general'] as List<String>?, ...?tagMap['character'] as List<String>?, }; if (tags.any(watchTags.contains)) { onHit(post); } }); }

这段代码监听post:create事件,从返回 JSON 里取标签,与本地 watchTags 集合做交集。watchTags.contains是 Set 查找,常数级复杂度,订阅几百个标签不会卡。注意 WebSocket 断线重连:移动网络切换会导致 ws 断开,要在 onDone 里做指数退避重连,退避上限 30 秒。e926 的 ws 端点地址和 e621 不是同一套,如果产品需要双站推送,建议在主站连接,镜像站用轮询降级。

订阅推送还可以和 ContentFilter 联动:即使标签命中,也先过一遍 sfwOnly 和黑名单再发通知,避免用户被不想看的内容震醒。我习惯把这条逻辑放在通知服务里,而不是 UI 页面里判断。做这类客户端的最后一条经验:所有请求统一走 SiteConfig、UA 和限速读取,不要为某个页面临时开特殊通道。e1547 这类项目真正值钱的部分不是某个炫酷页面,而是数据层边界反复打磨后可复用的稳定结构,把第一次失败请求的日志和响应头保留下来,以后再调别的社区 API,你会发现踩坑速度明显变慢。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询