Compare commits

...

4 Commits

Author SHA1 Message Date
shumengya
3e7216efb9 chore: sync local changes to Gitea 2026-06-24 22:10:29 +08:00
d452126069 feat(meme): 批量更新表情包素材与脚本
- 新增多个表情包分类与素材

- 更新转化.py、_redirects、memes.json 等配置

- 同步原图/ 与 public/meme/ 的本地处理结果
2026-06-06 16:59:09 +08:00
11d1be285f chore: 移除投稿相关文档与说明
- 删除 CONTRIBUTING.md

- README / blog-post / CHANGELOG / ASSET_LICENSE:移除所有投稿、贡献者、素材来源声明等表述

- .github/ISSUE_TEMPLATE / PULL_REQUEST_TEMPLATE:移除素材投稿选项、CONTRIBUTING.md 引用、素材来源声明

- public/readme/index.html:移除投稿指南链接与 CONTRIBUTING.md 引用

- public/index.html:补充 favicon、og:meta、GitHub 顶栏链接(上次未推送的改进)
2026-06-06 16:57:25 +08:00
1975e51960 docs: 完善仓库文档(README / LICENSE / CONTRIBUTING / CHANGELOG / Issue 与 PR 模板)
- README.md: 仓库门面,含特性、API 文档、快速开始、部署、目录结构、协议

- LICENSE: MIT,约束代码

- ASSET_LICENSE.md: CC BY-NC-SA 4.0,约束表情包素材

- CONTRIBUTING.md: 投稿流程、commit 与分支规范、PR 自检清单

- CHANGELOG.md: Keep a Changelog 格式,1.0.0 首发条目

- .gitignore: 补充 macOS/Windows/IDE/Python/日志等忽略项

- .github/ISSUE_TEMPLATE/: bug / feature / question 三个模板

- .github/PULL_REQUEST_TEMPLATE.md: 含素材来源声明与自检清单
2026-06-04 22:03:02 +08:00
1163 changed files with 5007 additions and 845 deletions

56
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,56 @@
---
name: 🐛 Bug 报告
about: 报告一个让你遇到问题的 bug前端 / API / 构建 / 素材缺失等)
title: '[Bug] '
labels: bug
assignees: ''
---
## 描述
简洁清晰地描述这个 bug 是什么。
## 复现步骤
1. 打开 `https://meme.smyhub.com/...`
2. 点击 `...`
3. 选择 `...`
4. 看到 `...` 而预期是 `...`
## 预期行为
你**期望**发生什么。
## 实际行为
实际发生了什么?错误信息、控制台报错、网络请求等。
## 截图 / 录屏
如果有的话,附上(拖拽到下面即可)。
## 环境
- 浏览器 / 版本:例如 Chrome 125.0 / Safari 17.5 / Firefox 127
- 操作系统:例如 Windows 11 / macOS 14 / iOS 17 / Android 14
- 设备:例如 PC / iPhone 14 / 小米 14
- 页面 URL例如 https://meme.smyhub.com/#白色小人
- 影响的素材路径(如果是某张图坏了):例如 `白色小人/123.webp`
## 浏览器控制台
> 打开 DevToolsF12→ Console 标签,把相关报错贴上来。
```
// 在此粘贴
```
## 额外信息
任何其他可能有用的上下文。
## 检查清单
- [ ] 我已经搜索过 [已有 Issue](https://github.com/shumengya/meme-api/issues?q=is%3Aissue) 确认没人提过
- [ ] 如果是素材问题,确认是仓库里 / 站点上都看不到,而不是我本地缓存
- [ ] 我没有在 Issue 里包含任何个人敏感信息

View File

@@ -0,0 +1,35 @@
---
name: 💡 功能建议
about: 对图库 / API / 构建流程提出改进建议
title: '[Feature] '
labels: enhancement
assignees: ''
---
## 这个功能解决什么问题?
简要说明痛点或动机。例如:
- "我每次都要手动选 HTML 宽度 240希望有记忆"
- "想在手机端把图片长按保存"
## 你想要的方案
描述你心中的实现方式。可以包括:
- UI 截图 / 草图
- 交互流程
- 对哪些 API / 文件有影响
## 备选方案
有没有考虑过别的做法?各自的优缺点?
## 额外信息
- 影响的页面 / API例如 `https://meme.smyhub.com/``/memes.json`
- 是否愿意自己提 PR是 / 否 / 待定
- 关联 Issue如果有#123
## 检查清单
- [ ] 我已经搜索过 [已有 Issue](https://github.com/shumengya/meme-api/issues?q=is%3Aissue) 确认没人提过

34
.github/ISSUE_TEMPLATE/question.md vendored Normal file
View File

@@ -0,0 +1,34 @@
---
name: ❓ 提问 / 求助
about: 不知道怎么用 / 需要帮助
title: '[Question] '
labels: question
assignees: ''
---
## 我想做什么
简述你想达到的目的。
## 我已经试过的
- 看了 README 里的「...」部分
- 搜了 Issues没找到
- ...
## 困惑点
具体卡在哪?
## 截图 / 代码
如果方便,附上相关截图或代码片段。
## 环境
- 浏览器 / 设备
- 访问的 URL
## 检查清单
- [ ] 我已经搜索过 [已有 Issue](https://github.com/shumengya/meme-api/issues?q=is%3Aissue) 确认没人提过

54
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,54 @@
## 描述
<!-- 一两句话说清这个 PR 干了什么。 -->
## 关联 Issue
<!-- 用 `Closes #123` / `Fixes #123` 关联 issue没关联的写 "无"。 -->
Closes #
## 变更类型
请打勾:
- [ ] 🐛 Bug 修复fix
- [ ] ✨ 新功能feat
- [ ] 🎨 样式 / UI 调整style
- [ ] ♻️ 重构refactor
- [ ] ⚡ 性能优化perf
- [ ] 📝 文档docs
- [ ] 🔧 构建 / 脚本 / 配置chore
## 改动内容
<!-- 详细列出做了哪些改动。可以按文件 / 模块分点。 -->
-
-
-
## 涉及的文件
<!-- 方便 reviewer 快速定位 -->
- [ ] `public/index.html`
- [ ] `scripts/generate-manifest.mjs`
- [ ] `转化.py`
- [ ] `public/memes.json`(清单,由 `npm run build` 自动重生成)
- [ ] `dist/memes.json`(构建产物,请确认未手动修改)
- [ ] 文档README / CHANGELOG / 其他)
- [ ] GitHub 配置Issue 模板 / PR 模板 / .github 目录)
## 自检清单
- [ ] 本地 `npm run build` 通过,`public/memes.json` 已重生成
- [ ] 本地 `npm run dev` 跑过,浏览器实测没回归
- [ ] 移动端DevTools 切到 375px布局没崩
- [ ] 改动不引入新外部依赖
- [ ] 没有把 `dist/``node_modules/``__pycache__/``*.pyc``原图/` 里的源文件等 commit 进去
- [ ] 我已更新 [CHANGELOG.md](./CHANGELOG.md)(如适用)
## 截图 / 录屏
<!-- 如有 UI 改动请附图。 -->

67
.gitignore vendored
View File

@@ -1,7 +1,74 @@
# ----- 构建产物 -----
dist/
node_modules/
.wrangler/
# ----- 临时 / 私密 -----
.dev.vars
.env
.env.local
*.local
tmp/
temp/
# ----- 日志 -----
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# ----- Python -----
__pycache__/
*.py[cod]
*$py.class
*.pyc
.venv/
venv/
env/
ENV/
.python-version
.mypy_cache/
.pytest_cache/
.ruff_cache/
*.egg-info/
# ----- 操作系统残留 -----
# macOS
.DS_Store
.AppleDouble
.LSOverride
._*
# Windows
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
Desktop.ini
$RECYCLE.BIN/
# ----- IDE / 编辑器 -----
.idea/
.vscode/
*.swp
*.swo
*.swn
*~
# ----- 备份 -----
*.bak
*.orig
*.rej
# ----- 工具缓存 -----
.eslintcache
.cache/
.parcel-cache/
.turbo/
# ----- 其它 -----
# 视频/音频预览(避免误提交原始素材)
*.psd
*.ai
*.sketch
*.fig

75
ASSET_LICENSE.md Normal file
View File

@@ -0,0 +1,75 @@
# 素材使用许可CC BY-NC-SA 4.0
本仓库中**所有表情包图片素材**(包括但不限于 `public/meme/``原图/``原图-已处理/` 目录下的所有 `.webp``.gif``.png``.jpg` 等图像文件)适用本许可。
> 本文件**仅约束素材**。本仓库的**代码**、**文档**、**配置文件**适用 [LICENSE](./LICENSE) 中规定的 **MIT 协议**。
---
## 中文摘要(仅供参考,完整条款以英文原文为准)
你可以:
-**共享** — 复制、转载、在其他网站 / 博客 / 聊天工具中使用
-**改编** — 二次加工、混剪、做自己的表情包
-**署名** — 需要给原作者 / 本仓库署名
在以下条件下:
- 📛 **署名BY** — 必须标明原作者 / 来源链接,**并标明是否做了修改**
- 🚫🚫 **非商用NC****不得用于任何商业目的**
- 🔁 **相同方式共享SA** — 如果你改编了,必须用**同样的 CC BY-NC-SA 4.0** 协议发布
## 完整法律文本
本作品采用 [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0)](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.zh-hans) 协议许可。
访问以下链接查看完整条款:
- 🇨🇳 简体中文:<https://creativecommons.org/licenses/by-nc-sa/4.0/deed.zh-hans>
- 🇺🇸 English<https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode>
## 推荐署名格式
在使用本仓库的表情包时,建议在可被看见的地方附上以下署名之一:
```
素材来自meme-api (https://github.com/shumengya/meme-api)
许可协议CC BY-NC-SA 4.0
```
如果你转载的是**单张表情**,可以简化为:
```
来源shumengya/meme-api (CC BY-NC-SA 4.0)
```
## 关于商用
CC BY-NC-SA 的 **NCNonCommercial** 含义:
- 🚫 不得用于售卖、付费内容、广告变现、商品印刷等任何**直接或间接**产生商业收益的场景
- ✅ 私人聊天、个人博客 / 哔哔 / Telegram 频道、**不盈利**的社交账号可放心使用
- ❓ 不确定?默认**视为商用**,或单独联系作者获取书面授权
如确有商用需求(广告、表情商店、付费内容、商品等),请提 [Issue](https://github.com/shumengya/meme-api/issues) 或直接联系作者协商。
## 侵权处理
如果你认为本仓库的某张表情包**侵犯了你的合法权益**(版权、肖像权、商标等),请:
1. 在仓库提一个 Issue 说明情况(推荐,便于追踪)
2. 或通过 GitHub 主页 <https://github.com/shumengya> 找到联系方式
收到通知后,作者会在合理时间内**核实、删除或调整**相关内容,并对历史仓库做必要的回滚。
## 关于本协议的选择
- 为什么不是 MIT表情包是**艺术作品 / 二次创作**,加上 **NC** 可以避免被无差别商用剽窃,加上 **SA** 可以让衍生作品保持开放。
- 为什么不是 CC BY-SANC 限制可以保护作者署名权益,避免"我做的素材被人用在付费壁纸 APP 里"。
---
<div align="center">
<sub>Last updated: 2026-06-04</sub>
</div>

48
CHANGELOG.md Normal file
View File

@@ -0,0 +1,48 @@
# 更新日志
本项目的所有**重要变更**都会记录在此文件。
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)
版本号遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
> 表情包素材的新增 / 删除 **不**计入版本号,详情看 Git 提交历史。
## [Unreleased]
### 计划中
- 给图库前端加 lightbox 预览(点击放大、左右切换)
- 在图库首页显示每个分类的封面图
- 素材缩放阈值参数化(支持多档位 CDN 输出)
## [1.0.0] - 2026-06-04
### 新增
- 仓库首次**公开**整理:补齐 README、LICENSE、CHANGELOG、Issue / PR 模板
- 双协议声明:代码 MIT表情包素材 CC BY-NC-SA 4.0
- 收录 **51 个分类、~2600+ 张**表情包WebP / GIF
- 图库前端(`public/index.html`):分类侧边栏 + 搜索 + 移动端 4 列 / 桌面 12 列网格
- 一键复制 URL / Markdown / HTMLHTML 宽度 96400px 可记忆)
- 一键下载原图
- 在线 API 说明页:`https://meme.smyhub.com/readme/`
- JSON 公开 API`/memes.json`(兼容 `/api/v1/memes`),已配置 CORS
- Python 一键处理脚本 `转化.py`Pillow多线程、动图 WebP→GIF、zip 批处理)
- Node 构建脚本 `scripts/generate-manifest.mjs`(仅生成清单,素材不重处理)
- Cloudflare Pages 部署支持Git 集成 + wrangler CLI 双通道
### 安全 / 性能
- 静态站点,**无服务端**,无任何用户数据收集
- 所有外链静态资源fonts、analytics 等)**未引入**
- CORS 头只对 `memes.json``/api/v1/memes` 开启,不影响其他资源
## 历史
在 1.0.0 之前,仓库作为个人项目维护,无版本号 / 变更日志。
[Unreleased]: https://github.com/shumengya/meme-api/compare/main...HEAD
[1.0.0]: https://github.com/shumengya/meme-api/releases/tag/v1.0.0
---
<div align="center">
<sub>本文件由维护者手工维护,欢迎在 PR 中追加。</sub>
</div>

41
LICENSE Normal file
View File

@@ -0,0 +1,41 @@
MIT License
Copyright (c) 2024-2026 shumengya and meme-api contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
NOTICE — 本仓库采用 双重许可 (Dual Licensing)
================================================================================
本 LICENSE 文件**仅适用于本仓库中的源代码部分**,包括但不限于:
- HTML / CSS / JavaScript 前端代码
- Python 脚本(转化.py 等)
- Node.js 脚本scripts/*.mjs
- Cloudflare Pages 配置文件_headers、_redirects 等)
- 文档README.md、CHANGELOG.md、CONTRIBUTING.md 等 Markdown 文本)
本仓库中**表情包素材**`public/meme/` 目录下的所有 .webp / .gif 文件,
以及 `原图/`、`原图-已处理/` 下的所有图片)适用单独的协议,请参阅:
-> ASSET_LICENSE.md CC BY-NC-SA 4.0
简言之:
· 代码MIT最宽松可用包括商用
· 素材CC BY-NC-SA 4.0,署名 + 非商用 + 相同方式共享

269
README.md Normal file
View File

@@ -0,0 +1,269 @@
<div align="center">
# 萌芽表情包图库 meme-api
一个部署在 **Cloudflare Pages** 的纯静态表情包图库 + 公开 API。
打开网页就能翻、一键复制链接 / Markdown / HTML也支持直链外链和 JSON 接口拉清单。
[![Site](https://img.shields.io/website?url=https%3A%2F%2Fmeme.smyhub.com&label=%E5%9C%A8%E7%BA%BF%E5%9B%BE%E5%BA%93&logo=cloudflare&style=flat-square)](https://meme.smyhub.com)
[![API](https://img.shields.io/badge/API-memes.json-orange?style=flat-square)](https://meme.smyhub.com/memes.json)
[![License: Code MIT](https://img.shields.io/badge/%E4%BB%A3%E7%A0%81-MIT-blue?style=flat-square)](./LICENSE)
[![License: Assets CC BY-NC-SA 4.0](https://img.shields.io/badge/%E7%B4%A0%E6%9D%90-CC%20BY--NC--SA%204.0-lightgrey?style=flat-square)](./ASSET_LICENSE.md)
[![GitHub stars](https://img.shields.io/github/stars/shumengya/meme-api?style=flat-square)](https://github.com/shumengya/meme-api/stargazers)
[![GitHub forks](https://img.shields.io/github/forks/shumengya/meme-api?style=flat-square)](https://github.com/shumengya/meme-api/network)
[![Last commit](https://img.shields.io/github/last-commit/shumengya/meme-api?style=flat-square)](https://github.com/shumengya/meme-api/commits/main)
[在线浏览](https://meme.smyhub.com) · [API 文档](https://meme.smyhub.com/readme/) · [更新日志](./CHANGELOG.md)
</div>
---
## ✨ 这是什么
| 端 | 说明 |
| --- | --- |
| 🌐 **网页图库** | 分类侧边栏 + 移动端 4 列 / 桌面 12 列网格,搜索、暗色模式、键盘可达 |
| 📋 **一键复制** | 点开任意表情可复制 URL / Markdown / HTMLHTML 宽度 96400px 可选) |
| ⬇️ **原图下载** | 直接下载原图,文件名保持原编号 |
| 🔗 **静态直链** | `/meme/<分类>/<编号>.webp` 可被任何网页、博客、聊天工具直接引用 |
| 📜 **JSON API** | `/memes.json`(兼容 `/api/v1/memes`)返回完整清单,**已配置 CORS** |
> 收录 **51 个分类、约 2600+ 张** WebP / GIF 表情包,全部经过统一处理:最长边缩放至 250px、动图统一存为 GIF。
## 🖼 截图
> 在线版:<https://meme.smyhub.com>
<details>
<summary>🖥️ 桌面端(点击展开)</summary>
```
┌──────────────────────────────────────────────────────────────┐
│ 萌芽表情包图库ヘ(=^・ω・^=)ノ 说明 · memes.json │
├──────────┬───────────────────────────────────────────────────┤
│ 分类 │ 白色小人 · 224 滑稽 · 282 金馆长熊猫 · 304 │
│ 🔍 搜索… │ 抹茶旦旦 · 227 抹茶旦旦2 · 120 … │
│ 全部 │ ┌────┬────┬────┬────┬────┬────┬────┬────┐ │
│ 白色小人 │ │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ … │ │
│ 白色小人2 │ └────┴────┴────┴────┴────┴────┴────┴────┘ │
│ … │ (12 列 × 自动行,点击任意缩略图弹出操作面板) │
└──────────┴───────────────────────────────────────────────────┘
```
</details>
<details>
<summary>📱 移动端(点击展开)</summary>
```
┌────────────────────────┐
│ 萌芽表情包图库 … │
├────────────────────────┤
│ ☰ 分类 │
│ 白色小人 · 224 │
│ 滑稽 · 282 │
│ 金馆长熊猫 · 304 │
│ … │
├────────────────────────┤
│ ┌──┬──┬──┬──┐ │
│ │1 │2 │3 │4 │ (4 列) │
│ ├──┼──┼──┼──┤ │
│ │5 │6 │7 │8 │ │
│ └──┴──┴──┴──┘ │
└────────────────────────┘
```
</details>
## 🚀 快速开始
### 在线使用
直接打开 <https://meme.smyhub.com>,分类、搜索、复制随便玩。
### 引用单张表情
任意 HTML / Markdown / 聊天工具里粘贴:
```markdown
# Markdown
![哈哈](https://meme.smyhub.com/meme/%E7%99%BD%E8%89%B2%E5%B0%8F%E4%BA%BA/1.webp)
```
```html
<!-- HTML宽度 240px自适应 -->
<img src="https://meme.smyhub.com/meme/%E7%99%BD%E8%89%B2%E5%B0%8F%E4%BA%BA/1.webp"
alt="哈哈" loading="lazy" width="240"
style="max-width:240px;width:100%;height:auto" />
```
> 路径里的中文 `分类` 名必须 URL 编码;图库的「复制链接」按钮已帮你编好。
### 拉取完整清单
```bash
curl -sS "https://meme.smyhub.com/memes.json"
# 老接口仍兼容:
curl -sS "https://meme.smyhub.com/api/v1/memes"
```
返回结构(节选):
```json
{
"generatedAt": "2026-05-19T13:01:45.229Z",
"categories": [
{
"id": "白色小人",
"name": "白色小人",
"items": [
{ "file": "1.webp", "path": "白色小人/1.webp" },
{ "file": "2.webp", "path": "白色小人/2.webp" }
]
}
]
}
```
拼接规则:`{BASE}/meme/{category.id}/{item.file}`
## 🛠 本地开发
> 需要 **Node.js ≥ 18** 和 **Python ≥ 3.8**(仅当你需要重新处理图片时才用得到 Python
```bash
# 1. 克隆
git clone https://github.com/shumengya/meme-api.git
cd meme-api
# 2. 安装依赖(只需要 wrangler体积很小
npm install
# 3. 构建(生成 dist/,含 memes.json
npm run build
# 4. 本地预览http://localhost:8788
npm run dev
```
可选的构建变体:
| 命令 | 作用 |
| --- | --- |
| `npm run build` | 默认构建。清单优先扫描 `public/meme/`(已拷入的成品),否则回退 `原图-已处理/` / `原图/` |
| `npm run build:manifest-from-source` | **强制**按 `原图-已处理/`(无则 `原图/`)生成清单,不看 `public/meme/` |
| `npm run dev` | 构建 + 起 `serve` 静态服务在 8788 端口 |
| `npm run deploy:preview` | 用 `wrangler` 部署到 Cloudflare Pages **预览**分支 |
| `npm run deploy:upload` | 用 `wrangler` 部署到 Cloudflare Pages **生产**分支(仅直连 CLI 的项目用) |
## 📦 添加 / 处理新表情
`public/meme/` 里已经是**可直接部署的成品**(已编号、已缩放、动图已是 GIF
当你拿到新的原图想加入,按下面流程走:
```bash
# 1. 把未编号的源素材丢进 原图/<分类名>/
mkdir -p 原图/我的新分类
cp ~/Downloads/xxx.png 原图/我的新分类/
# 2. 装依赖(一次性)
python -m pip install pillow
# 3. 转换:自动编号、统一 webp / gif、最长边缩放到 250px
python 转化.py
# 产物落到 原图-已处理/,原图默认保留;想清源可加 --delete-sources
# 4. 拷入发布目录
cp -r 原图-已处理/我的新分类 public/meme/
# 5. 重新生成清单
npm run build
```
> `转化.py` 的细节多线程、zip 批处理、WebP→GIF 动图等)见脚本顶部 docstring。
## 🚢 部署到 Cloudflare Pages
### 方式 A连接 Git推荐
1. 在 Cloudflare 控制台创建 Pages 项目,连接本仓库 `main` 分支
2. 构建设置:
- **构建命令**`npm run build`
- **构建输出目录**`dist`
3. 每次 `git push` 都会自动触发部署
### 方式 BCLI 直传
```bash
npm install
npx wrangler login # 第一次需要登录
npm run deploy:upload # 部署到生产
# 或
npm run deploy:preview # 部署到 preview 分支
```
> Git 项目里 `wrangler pages deploy` 通常会落到**预览**环境,属正常。
## 🗂 项目结构
```
meme-api/
├── 原图/ # 源素材(未编号、可能未处理)
├── 转化.py # Python 一键处理脚本Pillow
├── scripts/
│ └── generate-manifest.mjs # 生成 memes.json + 复制 public/ -> dist/
├── public/ # 构建源(直接作为站点根被复制)
│ ├── index.html # 图库前端(单文件,零依赖)
│ ├── _headers # CORS 等响应头
│ ├── _redirects # 兼容旧路径(/memes -> /
│ ├── memes.json # 由脚本生成的清单
│ ├── meme/ # 处理后的素材(已编号 .webp / .gif
│ └── readme/index.html # 在线 API 说明页
├── dist/ # 构建产物git 忽略)
├── package.json
└── .gitignore
```
## ⚙️ 配置
| 位置 | 作用 |
| --- | --- |
| `public/_headers` | `memes.json``/api/v1/memes` 的 CORS 头(已开启 `*` |
| `public/_redirects` | `/api/v1/memes``/memes.json``/memes{,/}``/` |
| `scripts/generate-manifest.mjs` | 清单生成策略、最大文件格式(`.webp/.gif`)、输出目录 |
如果想换 CDN 域名,可以在前端 JS 里给 `memes.json``baseUrl` 字段(详见脚本注释)。
## 📝 更新日志
见 [CHANGELOG.md](./CHANGELOG.md)。
## 📄 许可证
本仓库**双重许可**,请分别阅读:
| 内容 | 协议 | 链接 |
| --- | --- | --- |
| **代码**HTML / JS / Python / Node 脚本、`_headers``_redirects` 等) | MIT | [LICENSE](./LICENSE) |
| **表情包素材**`public/meme/` 下的所有 `.webp` / `.gif` 文件) | CC BY-NC-SA 4.0 | [ASSET_LICENSE.md](./ASSET_LICENSE.md) |
简言之:代码随便用、商用随便;素材**署名 + 非商用 + 相同方式共享**。
## 🙏 致谢
- [Cloudflare Pages](https://pages.cloudflare.com/) 提供静态托管
- [Pillow](https://python-pillow.org/) 提供图片处理能力
- [Cloudflare Pages](https://pages.cloudflare.com/) 提供静态托管
- [Pillow](https://python-pillow.org/) 提供图片处理能力
## 📬 联系
- Issue / PR<https://github.com/shumengya/meme-api/issues>
- 主页:<https://github.com/shumengya>
---
<div align="center">
<sub>本项目仅作表情包整理与分享;如素材涉及版权或肖像权问题,请提 Issue 或邮件联系,会第一时间处理。</sub>
</div>

1030
Typora_Hook_Log.txt Normal file

File diff suppressed because it is too large Load Diff

186
blog-post.md Normal file
View File

@@ -0,0 +1,186 @@
# [萌芽妙妙工具] 萌芽表情包图库 —— 一个纯静态的表情包 API + 图库
> 项目地址:[github.com/shumengya/meme-api](https://github.com/shumengya/meme-api)
> 在线体验:[meme.smyhub.com](https://meme.smyhub.com)
> 协议MIT协议
---
## 这是做什么的
平时在群里聊天、写博客、做项目文档的时候,经常需要找表情包。网上搜到的图要么带水印,要么链接失效,要么格式不统一,用起来很不顺手。
所以我整理了一个表情包图库,做成纯静态站点部署在 Cloudflare Pages 上。打开网页就能翻、能搜、能一键复制链接,也对外提供 JSON API方便在任何地方调用。
目前一共收录了 **51 个分类、2600 多张** WebP / GIF 表情包,全部经过统一处理(最长边缩放到 250px动图统一为 GIF体积和兼容性都控制得比较好。
---
## 打开网页就能用
图库本身是一个单文件 HTML 页面(`public/index.html`),没有任何前端框架、没有任何外部依赖,打开就是下面这样:
- 左侧分类侧边栏,支持搜索过滤
- 手机端自动变成 4 列网格,电脑端最多 12 列
- 点击任意表情弹出操作面板,可以:
- 复制直链 URL
- 复制 Markdown 格式
- 复制 HTML 嵌入代码(宽度 96~400px 可选,会记住你的偏好)
- 直接下载原图
整个页面加载完之后所有交互都是本地的,不需要后端服务。
---
## 也能当 API 用
如果你不想打开网页,直接用接口拉数据也可以:
```bash
curl -sS "https://meme.smyhub.com/memes.json"
```
返回结构大概长这样:
```json
{
"generatedAt": "2026-05-19T13:01:45.229Z",
"categories": [
{
"id": "白色小人",
"name": "白色小人",
"items": [
{ "file": "1.webp", "path": "白色小人/1.webp" },
{ "file": "2.webp", "path": "白色小人/2.webp" }
]
}
]
}
```
拿到清单之后,拼接一下就是直链:
```
https://meme.smyhub.com/meme/白色小人/1.webp
```
中文路径记得 URL 编码,图库里的「复制链接」按钮已经帮你编好了。
接口还做了 CORS任何网页、博客、脚本、聊天机器人都可以直接跨域调用。
---
## 引用单张表情
在 Markdown 或 HTML 里直接贴:
```markdown
![哈哈](https://meme.smyhub.com/meme/%E7%99%BD%E8%89%B2%E5%B0%8F%E4%BA%BA/1.webp)
```
```html
<img src="https://meme.smyhub.com/meme/%E7%99%BD%E8%89%B2%E5%B0%8F%E4%BA%BA/1.webp"
alt="哈哈" loading="lazy" width="240"
style="max-width:240px;width:100%;height:auto" />
```
因为托管在 Cloudflare Pages全球边缘节点都有缓存国内访问速度也还可以。
---
## 技术结构
整个项目非常轻,没有复杂架构:
```
meme-api/
├── public/
│ ├── index.html ← 图库前端(单文件,零依赖)
│ ├── meme/ ← 处理后的表情包素材
│ ├── memes.json ← 自动生成的清单
│ ├── _headers ← CORS 响应头配置
│ ├── _redirects ← 旧接口兼容
│ └── readme/index.html ← API 说明页
├── scripts/
│ └── generate-manifest.mjs ← 生成 memes.json + 复制到 dist/
├── 原图/ ← 原始素材
├── 转化.py ← Python 一键处理脚本
└── package.json
```
### 纯静态,没有后端
所有内容都是静态文件。分类列表、搜索、复制、下载,全部在前端用原生 JS 完成。部署只需要把 `dist/` 丢到任意静态托管就行Cloudflare Pages、Vercel、GitHub Pages、对象存储都可以
### 零依赖前端
`index.html` 是一个完整的单页面应用,内联了所有 CSS 和 JavaScript没有引用任何外部 CDN。这意味着
- 内网环境、离线环境也能打开
- 不用担心 CDN 挂掉导致页面崩
- 审计和安全层面很干净
### Python 处理脚本
新素材丢进 `原图/<分类>/`,运行:
```bash
python 转化.py
```
会自动完成:
- 未编号素材自动续号
- 统一转成 WebP静态或 GIF动图
- 最长边缩放到 250px
- 多线程并行处理
处理完的东西会输出到 `原图-已处理/`,再手动拷进 `public/meme/` 就可以上线。
---
## 协议说明
这个仓库用了**双重许可**,代码和素材分开:
| 内容 | 协议 |
|------|------|
| 代码HTML / JS / Python / Node / 配置) | MIT随便用商用也行 |
| 表情包素材(`public/meme/` 下的所有图片) | CC BY-NC-SA 4.0,署名 + 非商用 + 相同方式共享 |
---
## 部署方式
### 方式一:连接 Git推荐
在 Cloudflare Pages 创建项目,连接 GitHub 仓库的 `main` 分支:
- 构建命令:`npm run build`
- 构建输出目录:`dist`
每次 push 会自动触发部署。
### 方式二CLI 直传
```bash
npm install
npm run build
npx wrangler pages deploy dist
```
---
## 总结
这个项目本质上就是一个**整理好的表情包文件夹 + 一个能用的前端 + 一个能读的 JSON 接口**。没有花里胡哨的功能,但刚好解决了"找表情、发链接、统一管理"这几个常见痛点。
如果你平时也需要大量表情包,或者想给自己的项目搭一个简单的素材 API可以试着部署一下这个项目。表情包内容会不定期整理更新。
---
**相关链接**
- 🌐 在线图库:[meme.smyhub.com](https://meme.smyhub.com)
- 📦 GitHub 仓库:[github.com/shumengya/meme-api](https://github.com/shumengya/meme-api)
- 📖 API 文档:[meme.smyhub.com/readme/](https://meme.smyhub.com/readme/)

View File

@@ -1 +1,3 @@
/api/v1/memes /memes.json 200
/memes / 301
/memes/ / 301

View File

@@ -3,48 +3,952 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>meme-api</title>
<title>萌芽表情包图库(=・ω・=)</title>
<meta name="description" content="一个公开的表情包图库与 API分类浏览、一键复制链接 / Markdown / HTML51 个分类共 2600+ 张 WebP / GIF。" />
<meta name="theme-color" content="#FFCC4D" />
<meta property="og:title" content="萌芽表情包图库" />
<meta property="og:description" content="一个公开的表情包图库与 API分类浏览、一键复制链接 / Markdown / HTML。" />
<meta property="og:type" content="website" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='20' fill='%23FFCC4D'/><text x='50' y='74' text-anchor='middle' font-size='72' font-family='sans-serif' fill='%23000'>萌</text></svg>" />
<style>
:root { color-scheme: light dark; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "PingFang SC","Microsoft YaHei", sans-serif; line-height: 1.55; }
body { max-width: 52rem; margin: 2rem auto; padding: 0 1.25rem; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.92em; }
pre { overflow: auto; padding: 0.9rem 1rem; border-radius: 8px; background: color-mix(in oklab, Canvas 92%, CanvasText 8%); }
a { color: inherit; }
h1 { font-size: 1.35rem; }
h2 { font-size: 1.05rem; margin-top: 1.75rem; }
.muted { opacity: 0.78; font-size: 0.92rem; }
:root {
color-scheme: light dark;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto,
"PingFang SC", "Microsoft YaHei", sans-serif;
line-height: 1.45;
--bg: Canvas;
--fg: CanvasText;
--card: color-mix(in oklab, Canvas 94%, CanvasText 6%);
--border: color-mix(in oklab, Canvas 82%, CanvasText 18%);
--accent: color-mix(in oklab, CanvasText 12%, Canvas 88%);
--sidebar-w: 15rem;
--sidebar-w-collapsed: 2.75rem;
--topbar-h: 4.25rem;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
min-height: 100vh;
display: flex;
flex-direction: column;
}
body.sidebar-collapsed {
--sidebar-w: var(--sidebar-w-collapsed);
}
.top-bar {
flex-shrink: 0;
padding: 0.85rem clamp(0.75rem, 2vw, 1.25rem);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: color-mix(in oklab, var(--bg) 88%, transparent);
backdrop-filter: blur(10px);
z-index: 4;
}
.top-bar h1 {
font-size: 1.2rem;
margin: 0 0 0.25rem;
font-weight: 650;
}
.sub {
margin: 0;
font-size: 0.9rem;
opacity: 0.78;
}
.sub a {
color: inherit;
}
.layout {
display: flex;
flex: 1;
min-height: 0;
align-items: stretch;
}
.sidebar {
flex: 0 0 var(--sidebar-w);
width: var(--sidebar-w);
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
background: color-mix(in oklab, var(--bg) 96%, CanvasText 4%);
position: sticky;
top: var(--topbar-h);
height: calc(100vh - var(--topbar-h));
transition: width 0.22s ease, flex-basis 0.22s ease;
overflow: hidden;
z-index: 3;
}
.sidebar-head {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 0.45rem;
border-bottom: 1px solid var(--border);
}
.sidebar-toggle {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--border);
border-radius: 8px;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
font-size: 1.1rem;
line-height: 1;
-webkit-tap-highlight-color: transparent;
}
.sidebar-toggle:hover {
background: color-mix(in oklab, var(--accent) 65%, transparent);
}
.sidebar-toggle:focus-visible {
outline: 2px solid color-mix(in oklab, CanvasText 40%, transparent);
outline-offset: 2px;
}
.sidebar-title {
font-size: 0.82rem;
font-weight: 600;
opacity: 0.72;
white-space: nowrap;
overflow: hidden;
transition: opacity 0.15s ease;
}
body.sidebar-collapsed .sidebar-title {
opacity: 0;
width: 0;
overflow: hidden;
}
.sidebar-search {
flex-shrink: 0;
padding: 0 0.45rem 0.4rem;
}
body.sidebar-collapsed .sidebar-search {
display: none;
}
.cat-search-input {
width: 100%;
box-sizing: border-box;
font: inherit;
font-size: 0.82rem;
padding: 0.38rem 0.55rem;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: inherit;
}
.cat-search-input::placeholder {
opacity: 0.55;
}
.cat-search-input:focus {
outline: 2px solid color-mix(in oklab, CanvasText 28%, transparent);
outline-offset: 1px;
}
.cat-tabs-empty {
margin: 0.25rem 0.5rem;
font-size: 0.82rem;
opacity: 0.65;
}
.cat-tabs {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.15rem;
overflow-x: hidden;
overflow-y: auto;
padding: 0.45rem;
margin: 0;
scrollbar-width: thin;
-webkit-overflow-scrolling: touch;
}
.cat-tab {
flex: 0 0 auto;
width: 100%;
box-sizing: border-box;
font: inherit;
font-size: 0.84rem;
padding: 0.42rem 0.55rem;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
color: inherit;
cursor: pointer;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: background 0.12s ease, border-color 0.12s ease, opacity 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
body.sidebar-collapsed .cat-tab {
opacity: 0;
pointer-events: none;
height: 0;
padding: 0;
margin: 0;
border: none;
overflow: hidden;
}
.cat-tab:hover {
background: color-mix(in oklab, var(--accent) 65%, transparent);
}
.cat-tab[aria-selected="true"] {
font-weight: 650;
border-color: color-mix(in oklab, CanvasText 22%, var(--border));
background: color-mix(in oklab, var(--accent) 100%, transparent);
}
.cat-tab:focus-visible {
outline: 2px solid color-mix(in oklab, CanvasText 40%, transparent);
outline-offset: 2px;
}
.content {
flex: 1;
min-width: 0;
}
main {
padding: 1.25rem clamp(0.5rem, 2vw, 1rem) 3rem;
max-width: none;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.sidebar-overlay {
display: none;
position: fixed;
inset: var(--topbar-h) 0 0 0;
z-index: 2;
border: none;
padding: 0;
margin: 0;
width: 100%;
background: color-mix(in oklab, CanvasText 32%, transparent);
cursor: pointer;
}
@media (max-width: 640px) {
:root {
--sidebar-w: min(78vw, 15rem);
}
.layout {
display: block;
}
.sidebar {
position: fixed;
left: 0;
top: var(--topbar-h);
flex: none;
}
body.sidebar-collapsed .sidebar {
box-shadow: none;
}
body:not(.sidebar-collapsed) .sidebar {
box-shadow: 4px 0 24px color-mix(in oklab, CanvasText 18%, transparent);
}
body:not(.sidebar-collapsed) .sidebar-overlay {
display: block;
}
.content {
width: 100%;
}
}
/* 手机:每行固定 4 列 */
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.4rem 0.35rem;
}
/* 电脑:每行最多 12 列,占满可用宽度 */
@media (min-width: 769px) {
.grid {
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 0.55rem 0.5rem;
}
}
.tile {
display: block;
width: 100%;
border: none;
margin: 0;
padding: 0;
border-radius: 0;
background: transparent;
cursor: pointer;
text-align: center;
transition: opacity 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.tile:hover {
opacity: 0.88;
}
.tile:focus-visible {
outline: 2px solid color-mix(in oklab, CanvasText 40%, transparent);
outline-offset: 3px;
}
.tile img {
display: block;
width: 100%;
aspect-ratio: 1;
height: auto;
object-fit: contain;
vertical-align: middle;
border-radius: 0;
background: transparent;
}
.tile .label {
margin-top: 0.2rem;
font-size: 0.68rem;
opacity: 0.55;
word-break: break-all;
line-height: 1.2;
}
.toast {
position: fixed;
left: 50%;
bottom: 1.25rem;
transform: translateX(-50%) translateY(120%);
opacity: 0;
padding: 0.6rem 1rem;
border-radius: 10px;
background: color-mix(in oklab, CanvasText 88%, Canvas 12%);
color: Canvas;
font-size: 0.9rem;
transition: transform 0.22s ease, opacity 0.22s ease;
z-index: 9;
pointer-events: none;
max-width: min(92vw, 420px);
text-align: center;
}
.toast.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
.error {
padding: 1rem;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--card);
}
.empty {
opacity: 0.8;
padding: 1rem 0;
}
.sheet-root {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: flex-end;
justify-content: center;
padding: env(safe-area-inset-bottom, 0) 0.75rem 0.75rem;
box-sizing: border-box;
}
.sheet-root[hidden] {
display: none !important;
}
.sheet-backdrop {
position: absolute;
inset: 0;
background: color-mix(in oklab, CanvasText 38%, transparent);
border: none;
padding: 0;
cursor: pointer;
}
.sheet-panel {
position: relative;
width: min(22rem, 100%);
max-height: min(85vh, 28rem);
overflow: auto;
border-radius: 14px 14px 0 0;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
box-shadow: 0 -8px 32px color-mix(in oklab, CanvasText 22%, transparent);
padding: 0.65rem 0;
margin-bottom: 0;
}
@media (min-width: 480px) {
.sheet-root {
align-items: center;
padding: 1rem;
}
.sheet-panel {
border-radius: 14px;
margin-bottom: 0;
}
}
.sheet-title {
margin: 0;
padding: 0.35rem 1rem 0.65rem;
font-size: 0.82rem;
font-weight: 600;
opacity: 0.85;
word-break: break-all;
border-bottom: 1px solid var(--border);
}
.sheet-btn {
display: block;
width: 100%;
text-align: left;
font: inherit;
font-size: 0.95rem;
padding: 0.75rem 1rem;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
transition: background 0.12s ease;
-webkit-tap-highlight-color: transparent;
}
.sheet-btn:hover,
.sheet-btn:focus-visible {
background: color-mix(in oklab, var(--accent) 85%, transparent);
}
.sheet-btn:active {
opacity: 0.92;
}
.sheet-cancel {
margin-top: 0.25rem;
border-top: 1px solid var(--border);
text-align: center;
font-weight: 500;
opacity: 0.78;
}
.sheet-size-bar {
padding: 0.5rem 1rem 0.65rem;
border-bottom: 1px solid var(--border);
}
.sheet-size-label {
display: block;
font-size: 0.72rem;
opacity: 0.68;
margin-bottom: 0.45rem;
}
.sheet-size-btns {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.sheet-size-btn {
font: inherit;
font-size: 0.8rem;
font-weight: 600;
padding: 0.35rem 0.55rem;
min-width: 2.75rem;
border-radius: 8px;
border: 1px solid var(--border);
background: transparent;
color: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.sheet-size-btn.is-active {
border-color: color-mix(in oklab, CanvasText 28%, var(--border));
background: color-mix(in oklab, var(--accent) 100%, transparent);
}
.sheet-size-btn:focus-visible {
outline: 2px solid color-mix(in oklab, CanvasText 40%, transparent);
outline-offset: 2px;
}
</style>
</head>
<body>
<h1>meme-api</h1>
<p class="muted">静态站点(<a href="https://pages.cloudflare.com/">Cloudflare Pages</a>)。在 <code>原图/</code> 放未编号素材后运行 <code>python 转化.py</code>,输出到 <code>原图-已处理/</code>。将成品拷入 <code>public/meme/</code> 后运行 <code>npm run build</code>:只生成 <code>memes.json</code> 并把 <code>public/</code> 复制到 <code>dist/</code><strong>不会</strong>改写 <code>public/meme/</code>。清单默认按 <code>public/meme/</code> 扫描;若尚未拷素材则回退扫描 <code>原图-已处理/</code>(无则 <code>原图/</code>)。需强制按源目录生成清单时用 <code>npm run build:manifest-from-source</code>。部署目录为 <code>dist/</code>。直链可外部引用JSON 已配置 CORS。</p>
<p><a href="/memes/">浏览图库(分页、按文件夹分类、点击复制链接)</a></p>
<h2>获取表情包列表</h2>
<p><code>GET /memes.json</code>(与旧版兼容:<code>GET /api/v1/memes</code>,内部映射为同一份 JSON</p>
<p>返回所有分类及文件相对路径;前端与脚本可用其拼接直链。</p>
<pre id="curl-memes">curl -sS ""</pre>
<h2>直链访问单张表情包</h2>
<p>站点根目录对应构建产物 <code>dist/</code>(本地从 <code>public/</code> 同步而来);表情文件在 <code>/meme/&lt;分类&gt;/</code>,对应 URL</p>
<p><code>GET /meme/&lt;分类&gt;/&lt;文件名&gt;.webp</code><code>.gif</code></p>
<pre id="curl-example">https://example.com/meme/%E7%99%BD%E8%89%B2%E5%B0%8F%E4%BA%BA/1.webp</pre>
<p class="muted">中文或多段路径请使用编码后的 URL浏览器会自动处理图库「复制链接」亦为已编码地址</p>
<h2>维护流程</h2>
<ol>
<li><code>原图/&lt;分类&gt;/</code> 放入未编号素材;运行 <code>python 转化.py</code>,在 <code>原图-已处理/</code> 生成编号 <code>.webp</code>/<code>.gif</code> 并限最长边(默认 300px默认<strong>保留</strong> <code>原图/</code> 源文件,清理可加 <code>--delete-sources</code></li>
<li>动图 WebP 会在 <code>python 转化.py</code> 转换流程中转为 GIF。</li>
<li><code>npm run build</code>:生成 <code>dist/</code>(含 <code>dist/memes.json</code>)。请先把 <code>原图-已处理/</code> 拷入 <code>public/meme/</code> 再构建;构建脚本不会覆盖 <code>public/meme/</code></li>
<li><strong>上线(生产)</strong>:项目在 Cloudflare 已连接 Git 时,由推送生产分支触发云端构建:<code>npm run build</code><code>git add/commit/push</code>;仪表盘「构建命令」填 <code>npm run build</code>,「构建输出目录」填 <code>dist</code><code>wrangler pages deploy dist</code> 在 Git 项目里常显示为<strong>预览</strong>,属正常。</li>
</ol>
<header class="top-bar">
<h1>萌芽表情包图库ヘ(=^・ω・^=)ノ</h1>
<p class="sub">
<a href="/readme/">说明</a>
·
<a href="/memes.json"><code>memes.json</code></a>
·
<a href="https://github.com/shumengya/meme-api" rel="noopener" target="_blank">GitHub</a>
</p>
</header>
<button type="button" class="sidebar-overlay" id="sidebar-overlay" hidden aria-label="关闭分类"></button>
<div class="layout">
<aside class="sidebar" id="sidebar">
<div class="sidebar-head">
<button
type="button"
class="sidebar-toggle"
id="sidebar-toggle"
aria-expanded="true"
aria-controls="cat-tabs"
title="收起分类"
>
<span aria-hidden="true"></span>
</button>
<span class="sidebar-title">分类</span>
</div>
<div class="sidebar-search">
<input
type="search"
class="cat-search-input"
id="cat-search"
placeholder="搜索分类…"
autocomplete="off"
enterkeyhint="search"
/>
</div>
<nav class="cat-tabs" id="cat-tabs" role="tablist" aria-label="表情包分类"></nav>
</aside>
<div class="content">
<main id="app">
<p class="empty">加载中…</p>
</main>
</div>
</div>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<div id="sheet-root" class="sheet-root" hidden>
<button type="button" class="sheet-backdrop" id="sheet-backdrop" aria-label="关闭"></button>
<div class="sheet-panel" role="dialog" aria-modal="true" aria-labelledby="sheet-title">
<p id="sheet-title" class="sheet-title"></p>
<button type="button" class="sheet-btn" id="act-url">复制 url链接</button>
<button type="button" class="sheet-btn" id="act-md">复制 Markdown</button>
<div class="sheet-size-bar" id="sheet-size-bar" role="group" aria-label="HTML 宽度">
<span class="sheet-size-label">HTML 宽度</span>
<div class="sheet-size-btns">
<button type="button" class="sheet-size-btn" data-w="96" aria-pressed="false">96</button>
<button type="button" class="sheet-size-btn" data-w="128" aria-pressed="false">128</button>
<button type="button" class="sheet-size-btn" data-w="160" aria-pressed="false">160</button>
<button type="button" class="sheet-size-btn" data-w="200" aria-pressed="false">200</button>
<button type="button" class="sheet-size-btn" data-w="240" aria-pressed="false">240</button>
<button type="button" class="sheet-size-btn" data-w="280" aria-pressed="false">280</button>
<button type="button" class="sheet-size-btn" data-w="320" aria-pressed="false">320</button>
<button type="button" class="sheet-size-btn" data-w="400" aria-pressed="false">400</button>
<button type="button" class="sheet-size-btn" data-w="original" aria-pressed="false">原图</button>
</div>
</div>
<button type="button" class="sheet-btn" id="act-html">复制 HTML</button>
<button type="button" class="sheet-btn" id="act-download">下载</button>
<button type="button" class="sheet-btn sheet-cancel" id="act-close">取消</button>
</div>
</div>
<script>
(function () {
var o = window.location.origin;
document.title = "meme-api · " + o.replace(/^https?:\/\//, "");
document.getElementById("curl-memes").textContent = 'curl -sS "' + o + '/memes.json"';
document.getElementById("curl-example").textContent =
o + "/meme/" + encodeURIComponent("白色小人") + "/" + encodeURIComponent("1.webp");
const app = document.getElementById("app");
const tabsEl = document.getElementById("cat-tabs");
const sidebarToggle = document.getElementById("sidebar-toggle");
const sidebarOverlay = document.getElementById("sidebar-overlay");
const toastEl = document.getElementById("toast");
var SIDEBAR_KEY = "meme_sidebar_collapsed";
var categoryFilter = "";
function toAbsoluteUrl(url) {
if (!url) return url;
if (/^https?:\/\//i.test(url)) return url;
try {
return new URL(url, location.href).href;
} catch (e) {
return url;
}
}
function isSidebarCollapsed() {
return document.body.classList.contains("sidebar-collapsed");
}
function syncSidebarUI() {
if (!sidebarToggle) return;
var collapsed = isSidebarCollapsed();
sidebarToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
sidebarToggle.title = collapsed ? "展开分类" : "收起分类";
var icon = sidebarToggle.querySelector("span");
if (icon) icon.textContent = collapsed ? "" : "";
if (sidebarOverlay) sidebarOverlay.hidden = collapsed;
}
function setSidebarCollapsed(collapsed) {
document.body.classList.toggle("sidebar-collapsed", collapsed);
syncSidebarUI();
try {
localStorage.setItem(SIDEBAR_KEY, collapsed ? "1" : "0");
} catch (e) {}
}
function loadSidebar() {
var collapsed = false;
try {
if (window.matchMedia("(max-width: 640px)").matches) {
collapsed = localStorage.getItem(SIDEBAR_KEY) !== "0";
} else {
collapsed = localStorage.getItem(SIDEBAR_KEY) === "1";
}
} catch (e) {
collapsed = window.matchMedia("(max-width: 640px)").matches;
}
setSidebarCollapsed(collapsed);
}
if (sidebarToggle) {
sidebarToggle.addEventListener("click", function () {
setSidebarCollapsed(!isSidebarCollapsed());
});
}
if (sidebarOverlay) {
sidebarOverlay.addEventListener("click", function () {
setSidebarCollapsed(true);
});
}
loadSidebar();
const sheetRoot = document.getElementById("sheet-root");
const sheetTitle = document.getElementById("sheet-title");
const sheetBackdrop = document.getElementById("sheet-backdrop");
/** @type {{ url: string; file: string; category: string } | null} */
var sheetCtx = null;
/** @type {number | null} null = 原图尺寸 */
var selectedWidthPx = 240;
var WIDTH_STORAGE = "meme_copy_img_width";
var VALID_HTML_WIDTHS = [96, 128, 160, 200, 240, 280, 320, 400];
function loadSavedWidth() {
try {
var v = localStorage.getItem(WIDTH_STORAGE);
if (v === "original") selectedWidthPx = null;
else {
var n = parseInt(v, 10);
selectedWidthPx =
VALID_HTML_WIDTHS.indexOf(n) >= 0 ? n : 240;
}
} catch (e) {
selectedWidthPx = 240;
}
}
function persistWidth() {
try {
localStorage.setItem(
WIDTH_STORAGE,
selectedWidthPx == null ? "original" : String(selectedWidthPx)
);
} catch (e) {}
}
function syncSizeBarUI() {
var bar = document.getElementById("sheet-size-bar");
if (!bar) return;
bar.querySelectorAll(".sheet-size-btn").forEach(function (btn) {
var w = btn.getAttribute("data-w");
var active =
(w === "original" && selectedWidthPx == null) ||
(w !== "original" && parseInt(w, 10) === selectedWidthPx);
btn.classList.toggle("is-active", active);
btn.setAttribute("aria-pressed", active ? "true" : "false");
});
}
function wireSizeBar() {
document.querySelectorAll("#sheet-size-bar .sheet-size-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
var w = btn.getAttribute("data-w");
selectedWidthPx = w === "original" ? null : parseInt(w, 10);
persistWidth();
syncSizeBarUI();
});
});
}
loadSavedWidth();
wireSizeBar();
syncSizeBarUI();
function showToast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(showToast._t);
showToast._t = setTimeout(function () {
toastEl.classList.remove("show");
}, 2200);
}
async function copyText(text, toastMsg) {
try {
await navigator.clipboard.writeText(text);
showToast(toastMsg || "已复制到剪贴板");
} catch {
window.prompt("请手动复制:", text);
}
}
function escapeAttr(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;");
}
function markdownImage(url, alt) {
var a = String(alt || "表情").replace(/\]/g, "");
return "![" + a + "](" + url + ")";
}
function htmlEmbed(url, alt, widthPx) {
var parts =
'<img src="' +
escapeAttr(url) +
'" alt="' +
escapeAttr(alt || "") +
'" loading="lazy" draggable="false"';
if (widthPx != null) {
parts +=
' width="' +
widthPx +
'" style="max-width:' +
widthPx +
'px;width:100%;height:auto"';
}
return parts + " />";
}
function openSheet(url, file, category) {
sheetCtx = {
url: toAbsoluteUrl(url),
file: file,
category: category,
};
sheetTitle.textContent = (category ? category + " / " : "") + file;
syncSizeBarUI();
sheetRoot.hidden = false;
document.body.style.overflow = "hidden";
}
function closeSheet() {
sheetRoot.hidden = true;
document.body.style.overflow = "";
sheetCtx = null;
}
function wireSheetActions() {
sheetBackdrop.addEventListener("click", closeSheet);
document.getElementById("act-close").addEventListener("click", closeSheet);
document.getElementById("act-url").addEventListener("click", function () {
if (!sheetCtx) return;
copyText(sheetCtx.url, "已复制链接");
closeSheet();
});
document.getElementById("act-md").addEventListener("click", function () {
if (!sheetCtx) return;
var alt = sheetCtx.category + "/" + sheetCtx.file;
copyText(markdownImage(sheetCtx.url, alt), "已复制 Markdown");
closeSheet();
});
document.getElementById("act-html").addEventListener("click", function () {
if (!sheetCtx) return;
var alt = sheetCtx.category + "/" + sheetCtx.file;
var tip =
selectedWidthPx == null ? "已复制 HTML" : "已复制 HTML" + selectedWidthPx + "px";
copyText(htmlEmbed(sheetCtx.url, alt, selectedWidthPx), tip);
closeSheet();
});
document.getElementById("act-download").addEventListener("click", function () {
if (!sheetCtx) return;
var u = sheetCtx.url;
var name = sheetCtx.file;
closeSheet();
(async function () {
try {
var res = await fetch(u);
if (!res.ok) throw new Error("HTTP " + res.status);
var blob = await res.blob();
var objectUrl = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = objectUrl;
a.download = name;
a.rel = "noopener";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
showToast("已开始下载");
} catch (e) {
var link = document.createElement("a");
link.href = u;
link.download = name;
link.rel = "noopener";
link.target = "_blank";
document.body.appendChild(link);
link.click();
link.remove();
showToast("已尝试下载");
}
})();
});
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && !sheetRoot.hidden) closeSheet();
});
}
wireSheetActions();
function readHashCategory() {
var raw = location.hash.replace(/^#/, "");
if (!raw) return null;
try {
return decodeURIComponent(raw);
} catch (e) {
return raw;
}
}
function setHashCategory(id) {
var enc = encodeURIComponent(id);
if (location.hash !== "#" + enc) {
history.replaceState(null, "", "#" + enc);
}
}
function render(data) {
/** 可选 CDN 根;未设置时清单与 /meme 相对当前页面解析,支持子路径部署与本机 file:// 打开 */
var assetBase = data.baseUrl ? String(data.baseUrl).replace(/\/?$/, "") : "";
function memeAssetUrl(catId, fileName) {
var path =
"/meme/" +
encodeURIComponent(catId) +
"/" +
encodeURIComponent(fileName);
return toAbsoluteUrl(assetBase ? assetBase + path : path);
}
var categories = data.categories || [];
if (!categories.length) {
tabsEl.innerHTML = "";
app.innerHTML =
'<div class="empty">暂无数据。构建并部署后可在此查看。</div>';
return;
}
var selectedId = readHashCategory();
var validIds = {};
for (var i = 0; i < categories.length; i++) {
validIds[categories[i].id] = true;
}
if (!selectedId || !validIds[selectedId]) {
selectedId = categories[0].id;
setHashCategory(selectedId);
}
function findCategory(id) {
for (var j = 0; j < categories.length; j++) {
if (categories[j].id === id) return categories[j];
}
return categories[0];
}
var catSearchEl = document.getElementById("cat-search");
function renderTabs() {
tabsEl.innerHTML = "";
var q = categoryFilter.trim().toLowerCase();
var visible = 0;
for (var k = 0; k < categories.length; k++) {
var cat = categories[k];
if (q) {
var hay = ((cat.name || "") + " " + cat.id).toLowerCase();
if (hay.indexOf(q) < 0) continue;
}
visible++;
var btn = document.createElement("button");
btn.type = "button";
btn.className = "cat-tab";
btn.setAttribute("role", "tab");
btn.setAttribute("aria-selected", cat.id === selectedId ? "true" : "false");
btn.textContent = (cat.name || cat.id) + " · " + (cat.items || []).length;
btn.addEventListener(
"click",
(function (id) {
return function () {
if (selectedId === id) return;
selectedId = id;
setHashCategory(id);
renderTabs();
renderGrid();
if (window.matchMedia("(max-width: 640px)").matches) {
setSidebarCollapsed(true);
}
};
})(cat.id)
);
tabsEl.appendChild(btn);
}
if (q && visible === 0) {
var empty = document.createElement("p");
empty.className = "cat-tabs-empty";
empty.textContent = "无匹配分类";
tabsEl.appendChild(empty);
}
var selectedBtn = tabsEl.querySelector('[aria-selected="true"]');
if (selectedBtn) {
selectedBtn.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}
if (catSearchEl && !catSearchEl._wired) {
catSearchEl._wired = true;
catSearchEl.addEventListener("input", function () {
categoryFilter = catSearchEl.value;
if (categoryFilter.trim() && isSidebarCollapsed()) {
setSidebarCollapsed(false);
}
renderTabs();
});
}
function renderGrid() {
var cat = findCategory(selectedId);
var items = cat.items || [];
app.innerHTML = "";
var grid = document.createElement("div");
grid.className = "grid";
for (let n = 0; n < items.length; n++) {
const it = items[n];
const url = toAbsoluteUrl(it.url || memeAssetUrl(cat.id, it.file));
const tile = document.createElement("button");
tile.type = "button";
tile.className = "tile";
tile.title = it.file;
const img = document.createElement("img");
img.loading = "lazy";
img.alt = it.file;
img.src = url;
tile.appendChild(img);
const label = document.createElement("div");
label.className = "label";
label.textContent = it.file;
tile.appendChild(label);
tile.addEventListener("click", function () {
openSheet(url, it.file, cat.name || cat.id);
});
grid.appendChild(tile);
}
app.appendChild(grid);
}
renderTabs();
renderGrid();
window.addEventListener("hashchange", function () {
var hid = readHashCategory();
if (hid && validIds[hid] && hid !== selectedId) {
selectedId = hid;
renderTabs();
renderGrid();
}
});
}
fetch("/memes.json")
.then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then(render)
.catch(function (e) {
app.innerHTML =
'<div class="error">加载失败:<code>' +
String(e.message || e) +
"</code></div>";
});
})();
</script>
</body>

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

Before

Width:  |  Height:  |  Size: 476 KiB

After

Width:  |  Height:  |  Size: 476 KiB

View File

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Some files were not shown because too many files have changed in this diff Show More