技能说明
【小红书图文】用户在首页点了「一句话出小红书图文」。交付物:工作空间里的 post.md(5 个候选标题 + 正文 + 话题标签) 和 3~6 张 3:4 配图(1080×1440 PNG)。文字和图放在同一个文件夹里,用户点开就能照着发。
【铁律】
- 不做 AI 改图冒充实拍。 配图只有两种来源:① 用户给的真实照片,裁成 3:4、压暗加字;② 纯文字卡片(封面大字 + 内页要点)。 绝不生成"看起来像实物照片"的图,也不要把用户的产品图 P 成另一个样子 —— 发出去被认出来就是账号的事。
- 不许写绝对化用语和功效承诺(最好、第一、100%、根治、稳赚这类)。写完必须跑 xhs_lint.py 自检,有硬错就改到零。
- 用户没提供的事实不要编:没给价格就不写价格,没给参数就不写参数。宁可少写一句。
【工作流】
- 用 TodoWrite 列出:想角度 → 写正文 → 自检违禁词 → 出配图 → 交付,五步,不要再细拆。
列完先探一次出图环境(只一条命令,别等到第 5 步才发现缺库):
python3 -c "import PIL" 2>&1 | tail -1(Windows 用py -3)。报 ModuleNotFoundError 就按第 5 步的办法 立刻装上再往下写正文 —— 授权弹窗趁用户还在的时候弹,别让人走开一分钟回来看到一句提问。 - 想角度:按用户选的风格定调子 —— 种草="我用了之后解决了什么问题";干货="N 个方法/清单";测评="优点缺点都说,谁适合谁别买"。 用户给了素材文件夹就先用 fs/read 列一遍文件名,心里有数有哪些图能用;给了产品图就以图里真实可见的东西为准。
- 写 post.md,结构固定(xhs_lint.py 按这个结构解析,别改标题名):
## 标题(5 选 1):5 个候选,每个 ≤20 字。5 个要真的不一样 —— 痛点式、结果式、数字式、疑问式、反常识式各一个,不要同一句话换词。## 正文:300800 字(不含空白)。开头两行必须是钩子(说清楚"这篇解决谁的什么问题"),中间分 35 小段、每段一个点, 结尾一句行动引导。小红书正文用短句、多换行,可以用 emoji 但一段最多一个。## 标签:3~10 个#话题,大词(#好物分享)和精准词(#通勤包)各一半。
- 自检:用 fs/write 把下面这段代码原样写成工作空间里的 xhs_lint.py(一个字符都不要改),
再 exec 跑
python3 xhs_lint.py post.md(Windows 换成py -3,不要调 python3,那是应用商店的空壳别名)。 退出码 2 表示有硬错:照着它打印的词和长度问题改 post.md 再跑一次,改到退出码 0 或 1 为止(1 是提示,酌情处理)。 最多改 3 轮;3 轮还不过就把剩下的问题如实写在收尾说明里。自检完把 xhs_lint.py 删掉。
import json, re, sys
BANNED = ["最佳","最好","最优","最强","最低价","最便宜","最高级","最先进","最新科技","最新技术","第一品牌","全网第一","销量第一","排名第一","国家级","世界级","顶级","极品","极致","绝无仅有","独一无二","史无前例","前所未有","唯一","万能","100%","百分百","全网最低","永久","一劳永逸","治愈","根治","疗效","抗癌","防癌","消炎","杀菌","抑菌","祛疤","祛斑","生发","瘦身","减肥不反弹","躺瘦","包瘦","美白针","医用级","药用","稳赚","保本","零风险","包赚","暴富","躺赚","月入过万","稳定收益"]
RISKY = ["加微信","微信号","+V","加V","vx","VX","威信","私信我购买","扫码购买","淘宝搜","拼多多搜","闲鱼","点击链接","戳链接","全网首发","免费送","抽奖","代购","刷单","返现","好评返"]
text = open(sys.argv[1], encoding='utf-8').read()
def section(name):
m = re.search(r'^#{1,4}\s*' + name + r'.*$([\s\S]*?)(?=^#{1,4}\s|\Z)', text, re.M)
return m.group(1).strip() if m else ''
titles = [re.sub(r'^\s*(?:[-*]|\d+[.、])\s*', '', l).strip()
for l in section('标题').split('\n') if l.strip()]
titles = [t for t in titles if t]
body = section('正文')
tags = re.findall(r'#([^#\s]{1,20})', section('标签') or text)
hard, soft = [], []
for w in BANNED:
if w in text:
hard.append({'kind': 'banned', 'word': w})
for w in RISKY:
if w in text:
soft.append({'kind': 'risky', 'word': w})
if len(titles) != 5:
hard.append({'kind': 'titles', 'detail': '标题要 5 个候选,现在 %d 个' % len(titles)})
for t in titles:
if len(t) > 20:
hard.append({'kind': 'title-len', 'detail': '标题超 20 字:%s' % t})
body_len = len(re.sub(r'\s', '', body))
if not 300 <= body_len <= 800:
hard.append({'kind': 'body-len', 'detail': '正文 %d 字,要求 300~800' % body_len})
if not 3 <= len(tags) <= 10:
hard.append({'kind': 'tags', 'detail': '话题标签 %d 个,要求 3~10' % len(tags)})
print(json.dumps({'titles': len(titles), 'body_len': body_len, 'tags': len(tags),
'hard': hard, 'soft': soft}, ensure_ascii=False, indent=2))
for h in hard:
print('硬错:' + (h.get('word') or h.get('detail')))
for s in soft:
print('提示(可能限流):' + s['word'])
sys.exit(2 if hard else (1 if soft else 0))
- 出配图:同样把下面这段原样写成 xhs_cards.py,再用 fs/write 写一份 cards.json,跑
python3 xhs_cards.py cards.json .。- cards.json 是数组,每项:
{"file":"01-cover.png","type":"cover|inner","theme":"warm|clean|dark|green","title":"…","lines":["…"],"footer":"…","photo":"用户图的绝对路径(可选)"} - 张数按用户选的来(默认 4 张):1 张封面 + 2
5 张内页。封面标题就用 5 个候选里最好的那个,可以用 \n 手动断行,每行 ≤10 字; 内页一页一个要点,title 一句话、lines 24 条短句,别把正文整段贴进去。 - 用户给了实拍图就把绝对路径填进 photo,脚本会裁成 3:4 并压暗顶部;主题色四选一,一组图用同一个 theme 保持统一。
- 脚本打印
NEED_PILLOW说明本机没装 Pillow:直接 exec 跑python3 -m pip install --user pillow(timeout 300000)装上再重跑, 不要再用一句提问把交付打断 —— exec 本身就会弹授权弹窗、把这条命令原样给用户看,用户点允许就是同意(装依赖要经用户同意的规矩由弹窗满足)。 Windows 换成py -3 -m pip install --user pillow(禁止调 python3,那是应用商店的空壳别名)。 报No module named pip(精简版 Linux 常见)就先跑一次python3 -m ensurepip --user再装。 还是装不上(网络不通、pip 报错)才停:把报错原文和这条命令告诉用户,只交 post.md,在收尾说明里讲清楚图为什么没出。 同一条安装命令最多试 2 次,不要反复重装。 - 打印
NO_CJK_FONT说明系统没有中文字体(少见,多为精简版 Linux),这时也只交 post.md,不要硬出一堆方框图。 - 出图成功后跑一次
python3 -c "from PIL import Image;import glob;print([Image.open(f).size for f in sorted(glob.glob('*.png'))])"确认每张都是 1080×1440,再把 xhs_cards.py 和 cards.json 删掉。
- cards.json 是数组,每项:
- 交付:post.md 和 PNG 都在工作空间里,直接说文件名。没有 post.md 就不许说「已完成」; 图没出来就如实说卡在哪一步,不要编造。
- 收尾说明 6 行内:推荐用哪个标题(一句话说为什么)、正文多少字、几张图、自检结果(有没有剩下的风险词), 最后一句「想换个角度或改哪张图直接说」。
import json, os, sys
try:
from PIL import Image, ImageDraw, ImageFont, ImageFilter
except ImportError:
print('NEED_PILLOW: 请先安装 Pillow:python3 -m pip install pillow')
sys.exit(3)
W, H = 1080, 1440
THEMES = {
'warm': ((255, 240, 232), (61, 42, 34), (232, 106, 74)),
'clean': ((248, 248, 246), (34, 34, 34), (40, 110, 200)),
'dark': ((28, 28, 32), (245, 245, 245), (255, 186, 73)),
'green': ((236, 246, 238), (30, 60, 42), (46, 150, 90)),
}
FONT_DIRS = ['C:/Windows/Fonts', '/System/Library/Fonts', '/System/Library/Fonts/Supplemental',
'/Library/Fonts', '/usr/share/fonts', os.path.expanduser('~/.fonts')]
CANDIDATES = ['msyhbd.ttc', 'msyh.ttc', 'msyh.ttf', 'simhei.ttf', 'PingFang.ttc', 'Hiragino Sans GB.ttc',
'STHeiti Medium.ttc', 'NotoSansCJK-Bold.ttc', 'NotoSansCJKsc-Bold.otf',
'NotoSansCJK-Regular.ttc', 'SourceHanSansSC-Bold.otf', 'wqy-zenhei.ttc', 'DroidSansFallbackFull.ttf']
def find_font():
for d in FONT_DIRS:
for root, _, files in os.walk(d) if os.path.isdir(d) else []:
lower = {f.lower(): f for f in files}
for c in CANDIDATES:
if c.lower() in lower:
return os.path.join(root, lower[c.lower()])
return None
FONT_PATH = find_font()
if not FONT_PATH:
print('NO_CJK_FONT: 系统里没找到中文字体,无法出图')
sys.exit(4)
def font(size):
return ImageFont.truetype(FONT_PATH, size)
def wrap(draw, text, f, max_w):
lines, cur = [], ''
for ch in text:
if ch == '\n':
lines.append(cur); cur = ''; continue
if draw.textlength(cur + ch, font=f) > max_w and cur:
lines.append(cur); cur = ch
else:
cur += ch
if cur:
lines.append(cur)
return lines
def base_image(card, bg):
photo = card.get('photo')
if photo and os.path.exists(photo):
im = Image.open(photo).convert('RGB')
scale = max(W / im.width, H / im.height)
im = im.resize((round(im.width * scale), round(im.height * scale)), Image.LANCZOS)
im = im.crop(((im.width - W) // 2, (im.height - H) // 2,
(im.width - W) // 2 + W, (im.height - H) // 2 + H))
# 压暗顶部,保证白字可读;不改动照片内容本身
shade = Image.new('RGBA', (W, H), (0, 0, 0, 0))
ImageDraw.Draw(shade).rectangle([0, 0, W, int(H * 0.55)], fill=(0, 0, 0, 120))
return Image.alpha_composite(im.convert('RGBA'), shade).convert('RGB'), True
return Image.new('RGB', (W, H), bg), False
def render(card, out_dir):
bg, fg, accent = THEMES.get(card.get('theme', 'warm'), THEMES['warm'])
im, on_photo = base_image(card, bg)
if on_photo:
fg = (255, 255, 255)
draw = ImageDraw.Draw(im)
pad = 88
is_cover = card.get('type') == 'cover'
title = card.get('title', '')
tf = font(96 if is_cover else 64)
y = 180 if is_cover else 150
for line in wrap(draw, title, tf, W - pad * 2)[:4]:
draw.text((pad, y), line, font=tf, fill=fg)
y += tf.size + 24
if is_cover and not on_photo:
draw.rectangle([pad, y + 10, pad + 160, y + 24], fill=accent)
y += 60
bf = font(46)
y += 40
for raw in card.get('lines', []):
for line in wrap(draw, raw, bf, W - pad * 2):
if y > H - pad - bf.size:
break
draw.text((pad, y), line, font=bf, fill=fg)
y += bf.size + 20
y += 16
footer = card.get('footer')
if footer:
ff = font(34)
draw.text((pad, H - pad - ff.size), footer, font=ff, fill=accent)
out = os.path.join(out_dir, card['file'])
im.save(out, 'PNG')
return out
cards = json.load(open(sys.argv[1], encoding='utf-8'))
out_dir = sys.argv[2] if len(sys.argv) > 2 else '.'
os.makedirs(out_dir, exist_ok=True)
for c in cards:
print('OK ' + render(c, out_dir))
print('FONT ' + FONT_PATH)