一句话原理:图片本身没有加密。源站只是把标准 base64 的字符表换成了自定义字母表(把 + 换成 *、/ 换成 #),再把整段结果存成 .txt 文件。因此「解密」= 按换过的字母表做一次 base64 解码。
输入长这样(帖子里的图片地址,注意结尾是 .txt):
https://pic.xxx.top/hjstore/images/20260926/ea7293d561aa00ebf62910ef501dfbe2_mini.jpg.txt
把它下载下来,内容是类似这样的一串文本:
#FEyXSnnZVEl#R8oaFUlN1Ifa1T1MCutNVmtLlbDQTEB#ybC#1MGPjElR*I2...
解码之后得到的是 data URI(图片本体就在这里):
data:image/jpeg;base64,/9j/2wCEAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRof...
把 data URI 直接赋给 <img src> 即可显示;本接口做的就是这一步(并直接把图片二进制返回)。
自定义字母表(共 64 个字符,顺序即索引 0~63):
ABCD*EFGHIJKLMNOPQRSTUVWX#YZabcdefghijklmnopqrstuvwxyz1234567890
它就是标准 base64 表的变体:A-Z(26) + a-z(26) + 0-9(10) = 62 个,再加上 * 和 # 补足 64 个。
完整解码步骤:
① 去掉所有不属于字母表的字符(换行、空格、以及作为补位的 = 号全部丢弃);
② 从文本开头起每 4 个字符一组,用上表把每个字符换成它的下标(6 bit);
③ 4 个下标拼成 24 bit,按 8 bit 切回 3 个字节:
字节1 = (下标1 << 2) | (下标2 >> 4)
字节2 = ((下标2 & 15) << 4) | (下标3 >> 2) ← 下标3 为「补位」时不要输出
字节3 = ((下标3 & 3) << 6) | 下标4 ← 下标4 为「补位」时不要输出
④ 得到的字节序列按 UTF-8 解码,就是 data:image/...;base64,... 这段文本。
⑤ 若末尾不足 4 个字符:按「下标 0」(即字母表首字符 A)补齐。这一步是为了与源站前端实现完全一致——它用的是 charAt 越界返回空串、indexOf("") 得 0,效果等价于补 A。
为什么不能直接用标准 base64 解?
因为字符集被换过:文本里出现的 * 和 # 在标准 base64 里不是合法字符,直接解会报错或得到乱码;反之标准表里的 + 和 / 也不会出现在这段文本里。必须换成上面那张表。
Python 参考实现:
import re
ALPHABET = "ABCD*EFGHIJKLMNOPQRSTUVWX#YZabcdefghijklmnopqrstuvwxyz1234567890"
def decode_image(text: str) -> str:
s = re.sub(r"[^A-Za-z0-9*#]", "", text) # ① 清洗
if len(s) % 4: # ⑤ 末尾补齐
s += ALPHABET[0] * (4 - len(s) % 4)
out = bytearray()
for i in range(0, len(s), 4): # ②③ 每 4 字符还原 3 字节
a, b, c, d = (ALPHABET.index(ch) for ch in s[i:i + 4])
out.append(((a << 2) | (b >> 4)) & 0xFF)
if c != 64:
out.append((((b & 15) << 4) | (c >> 2)) & 0xFF)
if d != 64:
out.append((((c & 3) << 6) | d) & 0xFF)
return out.decode("utf-8") # ④ 得到 data URI 文本
# 用法:解码结果可直接作为 <img src> 使用
text = requests.get("https://…/xxxx_mini.jpg.txt").text
data_uri = decode_image(text) # -> data:image/jpeg;base64,....
JavaScript 参考实现(与源站前端同一套逻辑):
function decodeImage(txt) {
const E = "ABCD*EFGHIJKLMNOPQRSTUVWX#YZabcdefghijklmnopqrstuvwxyz1234567890";
const t = txt.replace(/[^A-Za-z0-9*#]/g, "");
const at = (i) => (i < t.length ? E.indexOf(t.charAt(i)) : 0); // 越界按下标 0
let d = "", l = 0;
while (l < t.length) {
const a = at(l++), b = at(l++), c = at(l++), e = at(l++);
d += String.fromCharCode((a << 2) | (b >> 4));
if (c !== 64) d += String.fromCharCode(((b & 15) << 4) | (c >> 2));
if (e !== 64) d += String.fromCharCode(((c & 3) << 6) | e);
}
return new TextDecoder("utf-8").decode(Uint8Array.from(d, (ch) => ch.charCodeAt(0)));
}
常见坑:
· 字母表区分大小写,且组内顺序是「4 字符 → 3 字节」,别按标准 base64 的 +/ 去替换;
· .txt 内容里的 = 补位会被第 ① 步过滤掉,所以末尾通常不足 4 字符,必须按第 ⑤ 步补齐;
· 文件名带 _mini 的是列表页下发的缩略图,详情页下发的是原图,两者解码方式完全相同;
· 解码结果以 data:image/ 开头才说明解对了(解错会得到乱码或非法 UTF-8)。
本接口的额外约束(防滥用):只接受 http/https 公网地址(拒绝内网 / 回环 / 保留地址)、不跟随重定向、单张体积上限 5MB,并且校验解码结果必须是图片(data:image/*),否则返回 EXTERNAL_API_FAILED——因此它不能被当作通用网页代理使用。