
简介这份压缩包是适用于e621与e926平台的移动应用源码基于Flutter/Dart构建面向希望研究或二次开发此类移动客户端的用户与Flutter开发者。应用覆盖帖子与画集浏览搜索、评论与修改、图片下载、上传与访问收藏、标签Wiki查询、本地黑名单、DText解析、视频播放及多种主题等完整功能并包含自动更新检查逻辑。资源共185个文件以108个dart源码文件为核心配以png/jpg/svg图片资源、iOS与Android工程配置gradle/plist/storyboard等以及少量json、md文档整体压缩包大小约29.59MB。目前已有3147人下载学习。通过查看源码可掌握Flutter移动应用的项目架构、状态管理与原生平台配置思路也可基于本地黑名单、DText解析等模块快速拓展自己的客户端功能。1. e1547 是什么给 Furry 社区做移动端难点不在 UI 而在双站 APIe1547 是我在维护的一个第三方移动应用目标很直接让 e621 和 e926 这两个 Furry 图站能在手机上好好用。e621 的桌面网页很完整但一上手机就全是缺点搜索词一长就不好输入、无限滚动没有、图片加载没有本地缓存、翻页逻辑藏得深e926 的情况类似只是内容更干净。我把它俩的 API、账号体系和内容过滤逻辑收进一个 App就是 e1547。它适合两类人一是想给这两个站做一个顺手客户端的开发者二是需要批量浏览、离线缓存和自定义筛选的深度用户。这个标题真正值得拆的不是 UI 怎么做而是双站 API 的差异与内容分级的落地。2. 拆解 e621 / e926 的 API同源异站接口差异比你想的大2.1 两个域名一套账号认证与 baseUrl 必须分开配e621 与 e926 共享同一套用户数据库因此你在 e621.net 注册的账号、生成的 API Key在 e926.net 上同样有效。这是好消息省去了双站两套账号的维护但坏消息是这两个站点并不共享“内容访问状态”尤其对成人向作品e621 要求请求方携带登录身份匿名请求在多数情况下拿不到完整的 explicit 内容。所以客户端网络层的第一件事就是把“账号身份”放到每个请求里。常见做法是 HTTP Basic Auth用户名和密码直接用 API Key 填充而不是网页登录的密码。API Key 在 e621 的账号设置页里生成输入当前密码后会出现一串随机字符把它存进 App 的设置项即可。curl -u your_username:your_api_key \ -H User-Agent: e1547/1.0 (mobile; contactdevexample.com) \ -H Accept: application/json \ https://e621.net/posts.json?limit10这里的核心点有两个一是-u参数把账号与密钥一起交给服务端不要用单独的password参数二是请求的目标域名要区分e621 与 e926 的 baseUrl 完全不同放到配置里作为两个独立环境切换时全局生效。参数limit建议按列表页需求设为 20 或 50不要贪大后面避坑部分会解释原因。2.2 请求头三件套User-Agent、Basic Auth、Accept 一个都不能少刚接触这个 API 时最容易踩的坑是只带 Basic Auth 就发请求结果 403 一片。官方的公开接口要求每个请求携带有意义的 User-Agent里面至少包含应用名和联系方式。这是一个反爬性质的约束但换来的好处是接口对真正的客户端比较宽容。curl -u your_username:your_api_key \ -H User-Agent: e1547/1.0 (contactdevexample.com) \ -H Accept: application/json \ https://e926.net/posts.json?limit20为了让这个约束可维护我一般会在网络层用一个常量保存 UA并挂进每个请求的 header。注意 UA 里的 contact 字段尽量用真实邮箱或项目主页别写 placeholder否则真遇到问题被服务端拉黑时连申诉都没法解释。Accept 则固定application/json保证响应不被当成 HTML 返回。2.3 e926 的过滤是服务端行为改参数删不掉的内容e926 的定位是“内容已过滤”的浏览端点它在服务端直接剔除了一部分图片而不是通过某个参数控制。这一点容易被理解错如果你在 e621 的请求里加-explicit标签得到的是客户端过滤后的结果而 e926 的过滤发生在查询层服务端只会把审核通过的内容放进结果集。也就是说同一个tagscanine请求发到 e621 和 e926返回数量可能有数量级差异。e926 下你无法通过rating:explicit这类标签强行拉回内容服务端会直接忽略或给出空结果。因此在设计客户端时不要把“切换 e926”做成一个简单的 baseUrl 替换而要把它当成一种“内容安全模式”来建模后面第 4 章会详细说。3. 用 Flutter 搭出最小可用的客户端骨架网络层、搜索接口、图片缓存3.1 工程结构与网络层配置Dio 拦截器只做三件事项目本身不复杂但网络层必须独立成模块。我习惯用 Flutter 的 Dio 库因为拦截器机制很好扩展在请求发出前加 header、在响应回来时统一处理容错。e1547 的网络模块只做三件事设置基础配置、注入用户身份、对 429 做退避重试。import package:dio/dio.dart; import dart:convert; class E621Api { static const String _ua e1547/1.0 (contactdevexample.com); final bool safeMode; final String username; final String apiKey; late final Dio _dio; E621Api({ required this.safeMode, required this.username, required this.apiKey, }) { _dio Dio(BaseOptions( baseUrl: safeMode ? https://e926.net : https://e621.net, headers: { User-Agent: _ua, Accept: application/json, }, )); _dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) { final auth base64Encode(utf8.encode($username:$apiKey)); options.headers[Authorization] Basic $auth; handler.next(options); }, onError: (e, handler) async { if (e.response?.statusCode 429 e.requestOptions.extra[retryCount] null) { e.requestOptions.extra[retryCount] 1; await Future.delayed(const Duration(seconds: 2)); handler.resolve(await _dio.fetch(e.requestOptions)); return; } handler.next(e); }, )); } }逻辑说明构造器根据safeMode决定 baseUrl这样全局只有一个切换开关拦截器在请求发出前注入 Basic Auth避免每个请求都写一遍鉴权代码429 时延迟两秒重新发送原请求retryCount用来限制只重试一次。参数username和apiKey应从配置页传入不要写死在代码里。这里有一个玄学点e926 与 e621 的 baseUrl 虽然不同但路径结构完全一致所以网络层不需要为两站分别写函数只换配置即可。3.2 请求搜图接口tags、limit、page 的拼法搜图是核心接口请求路径是/posts.json。它的查询参数不多但拼法有些讲究tags支持空格表示 AND~表示 OR-表示 NOTlimit决定单页条数最大 320page从 1 开始。FutureListPost searchPosts({ required String tags, int page 1, int limit 50, }) async { final resp await _dio.get(/posts.json, queryParameters: { tags: tags, page: page, limit: limit, }); final map resp.data as MapString, dynamic; return (map[posts] as List) .map((e) Post.fromJson(e)) .toList(); }代码说明queryParameters 里只放三个固定参数复杂搜索句式交给用户输入Post.fromJson负责把响应里的原始 JSON 转成模型。注意响应结构是一个对象键名为posts不是数组本体我第一次对接时在类型上绕了一小圈。搜索页需要注意标签输入框的即时提示。虽然 API 里没有免费的自动补全接口但tags.json可以按前缀查建议用户输入超过两个字符后再请求避免把 API 请求打爆。3.3 图片加载与缓存一张 Post 卡片怎么下列表页的图片加载是移动端体验的分水岭直接请求原图会让列表卡顿到不可用。e621 的响应里提供了三组 URLpreview.url、sample.url和file.url对应小图、压缩大图和原图。我在卡片列表里用preview.url点击进详情再用sample.url原图只在用户主动要求时加载。import package:cached_network_image/cached_network_image.dart; Widget postThumbnail(Post post) { return CachedNetworkImage( imageUrl: post.previewUrl, placeholder: (context, url) const CircularProgressIndicator(), errorWidget: (context, url, error) const Icon(Icons.broken_image), cacheKey: ${post.baseUrl}#${post.id}#${post.previewUrl}, ); }这里的cacheKey是必须写的。默认情况下该插件会用 URL 做缓存标识看起来没什么问题但 e621 与 e926 的过滤结果集同源切站后同样的post.id可能指向不同地址导致列表页出现“串图”。把baseUrl、id、图片地址三者拼进 cacheKey能彻底隔离两个站的缓存。参数placeholder与errorWidget是加载中与失败态的占位控件不要省略图片占位是列表滚动流畅的关键一环。4. 内容分级的双轨实现SFW 模式与本地过滤4.1 rating 字段把服务端分级直接透传到 UIe621 的每个 post 都带一个rating字段取值是safe、questionable、explicit三档。在 e621 模式下客户端不需要拦截直接展示即可但在 e926 模式下服务端虽然已经过滤了大多数内容偶尔仍会出现questionable级别的图片因为审核尺度并不是只按 rating 一刀切。我在 UI 层采用“双轨透传”设置里的安全开关决定是否展示 questionable 和 explicit。安全模式打开时在请求侧不额外追加过滤参数但在模型层加一个acceptable()方法任何页面渲染前都先过这个方法避免某些漏网图片突然出现在屏幕上。enum ContentRating { safe, questionable, explicit } bool acceptable(Post post, {required bool safeMode}) { if (!safeMode) return true; return post.rating ContentRating.safe; }这里的设计动机是e926 服务端过滤属于黑匣子我们不能假设它永远按我们期望的方式工作所以客户端自己在渲染前再做一次校验。这个校验不是防爬而是防“切换安全模式后老旧缓存被展示出来”。4.2 本地黑名单过滤比服务端黑名单更快e621 账号本身带黑名单功能但它是服务端生效的命中黑名单的 post 会返回一个blacklist_reason字段然后在客户端才进入过滤流程。这个方案的问题是响应已经传过来了白费了流量和解析时间。深度用户常用的做法是把常用黑名单同步到客户端本地在解析后立即过滤。class LocalFilter { final SetString blockedTags; bool shouldSkip(Post post) { if (post.blacklistReason ! null) return true; return post.tags.values .expand((tags) tags) .any(blockedTags.contains); } }逻辑说明post 的tags在 JSON 里按artist、character、general、copyright等分类存放post.tags.values把所有分类展开成一个大列表再和本地黑名单集合做交集判断若任何一个命中直接跳过。这个判断在 parse 阶段做比渲染前过滤省下一大截内存。黑名单集合建议做成SetString利用字符串哈希把每条判断压到毫秒级。注意黑名单要支持general之外的标签比如按角色名屏蔽时用户输入的是character:xxx展开后的实际标签不带分类前缀所以本地存储时要先做一次归一化。4.3 账号黑名单把 blacklist_reason 用起来服务端账号黑名单不是完全没有用。它的好处是影响所有设备包括别的第三方客户端和网页端。我一般在设置页保留两个入口“本机黑名单”和“账号同步黑名单”。前者从账号的现有接口把当前黑名单配置拉下来再编译为本地集合后者保留用户在服务端的原始设置。这里有一个容易被忽略的点blacklist_reason字段会对匹配内容做说明客户端拿到后直接显示在页面上可以提示用户“这条被你自己的黑名单遮掉了”而不是莫名其妙“不行”。这个字段在 e621 与 e926 行为一致按第 2 章的缓存思路本地过滤结果不用区分站点因为它只依赖 post 自身的标签。5. 避坑清单4 个让双站客户端翻车的真实问题5.1 请求被 429 / 403限流与身份识别是两码事现象列表快速上滑时请求开始成片失败日志里出现 429如果完全不带 API Key还会出现 403。原因429 是服务端限流说明我们的请求频率太密403 则往往是身份识别失败最常见的是把账号名和密码填反、或者 API Key 被复制时多带了换行。不要把这两者混为一谈403 改慢速度没用。解决先确认每个请求都携带了正确的 Authorization 头再控制请求间隔。社区通行的经验是 1 秒以上我的做法是给 Dio 的限流器加一个强制节流相邻请求之间至少 800ms 间隔批量抓取时再加随机抖动 200–500ms。429 响应时按 3.1 的拦截器做退避而不是无限重试重试三次后抛出错误提示用户稍后。5.2 e926 的结果集缩水不是 bug是审核策略现象同一个关键词在 e621 能翻几十页切到 e926 只剩几页有些图片在 e621 明明存在在 e926 里怎么搜都搜不到。原因e926 服务端对内容做了过滤优先返回 safe 级别的内容explicit 大部分不会出现questionable 也是部分保留。这个行为与标签无关是站点定位决定的。解决在设计上不要把 e926 当成“性能更好的 e621 镜像”在切站提示里明确告诉用户“当前为安全模式部分搜索结果缺失属于正常现象”。不要在客户端里做“翻页补数”的伪逻辑那只会加重限流而且补不回来。模式切换只改 baseUrl 和 UI 上的安全开关不做内容修复。5.3 缓存 key 只写 id切站后图片列表“串味”现象从 e926 切回 e621 首页图片一闪过后又变了或者加载出上一站的安全版图片。原因cached_network_image默认以 URL 作为缓存 key如果我们在代码里只写了post.id两个站相同 id 的内容会命中同一个缓存条目。e621 与 e926 内容同源但图片文件并不保证完全一致安全模式下被审核后的版本与原始版本可能不同。解决按 3.3 节的写法缓存 key 用baseUrl#id#url三元组如果不想让缓存太分散也可以用md5(file.url)作为短 key但至少要保证 key 包含所属站点前缀。这个坑在开发初期很难发现因为你只在单站测试。5.4 API Key 混淆成 Session登录态与密钥要分开存现象用户反映“我明明登录成功了但请求还是 401”。原因e621 的网页登录走的是 Session/Cookie 机制而移动端 API 走 Basic Auth。在设置页里如果只让用户填账号和密码应用可能误把密码当作 API Key 用或者用户把网页签发的临时会话当成了密钥。解决设置页明确写“API Key”并在输入框说明生成路径密钥字段单独存储不要放进 Cookie 容器。请求 401 时在 UI 上给出跳转到账号设置页的提示而不是一句“登录失败”把用户晾着。6. 进阶给搜索加自定义排序与图片秒看缓存6.1 用评分公式做本地精排服务端默认排序在多数场景够用但深度用户往往需要按“自己关注的角色 高分 新图”排序。客户端拿到一页 50 条后可以在本地再排一次序因为这一页的数据量足够小开销几乎可以忽略。double localScore(Post post, SetString preferredTags) { double score 0; score (post.favCount ?? 0) / 100.0; score post.scoreTotal / 10.0; score post.tags[general]! .where(preferredTags.contains) .length * 2.0; return score; }各权重的取值是我自己反复试出来的favCount 反映了社区沉淀热度除以 100 是为了把它的量级压到和标签命中差不多scoreTotal 是点赞减踩的总分除以 10 控制波动每个偏好标签命中给 2 分让小众但精准的内容能浮上来。这套公式没有标准答案你可以在设置页暴露“权重系数”按自己的浏览习惯微调。6.2 用 md5 做收藏去重一条引用走天下e621 的 post 里有file.md5字段同一张图换 clip 或重投时 id 会变但 md5 不变。本地收藏用 md5 作为主键既能做到去重又能和 API 的tagsmd5:xxx查询互相引用点击收藏时直接调接口找回这图的所有版本。这是我在 e1547 里最满意的一个设计双站共享一套收藏数据不因站点切换或搜索结果漂移而重复。我个人的习惯是任何涉及“外链图片”的字段先用 md5 做索引再回退到 id。缓存目录、收藏表、去重列表都按这个原则维护能省去后期大量清洗数据的痛苦。这条路走通之后e621 和 e926 的双站客户端才算真正闭环希望帮到你。本文还有配套的精品资源点击获取