name: meiticaigou-publisher description: "Publish changing user-provided articles on meiticaigou.com (媒体采购网/广告采购网) through one canonical Chrome MCP workflow. Use when the current task supplies PPTX, DOCX, pasted text, image folders, or mixed material and Codex must build a fresh content plan, extract original images, fill the logged-in UEditor draft, upload every image strictly one at a time, preserve the current article's order and formatting, verify the live DOM, and stop at the user-authorized stage."
媒体采购网发布
使用一条最优主路径:把本次素材规范化为动态内容计划,再通过 Chrome MCP 完成编辑、上传、排版和核验。仅在主路径确实受阻时启用同一真实 Chrome 的备用控制逻辑。
铁律
- 内容完全来自本次素材和用户指示。不得复用旧标题、正文、图片、媒体或图文结构。
- 不硬编码品牌、段落数、图片数、文件名或顺序。
- 始终使用用户日常登录的同一个真实 Chrome。主路径使用 Chrome MCP;备用路径只允许 Computer Use 控制该 Chrome,不使用扩展或临时浏览器。
- 不使用系统剪贴板,不进入源码粘贴模式,不使用网站“一键自动排版”。
- 只上传原始图片;每张远端校验完成后再上传下一张。
- 不猜选媒体,不越过用户授权的保存、下一步、付款或发布阶段。
- Chrome MCP 不可用或关键控件无法操作时,按“备用逻辑”降级;不要无理由提前切换。
唯一执行路径
1. 生成本次 content_plan
无论输入是 PPTX、DOCX、粘贴文本、图片目录或混合素材,都先转换成同一结构:
{
"title": "本次标题",
"items": [
{"type": "text", "text": "第一段"},
{"type": "image", "path": "/absolute/path/image.png"},
{"type": "text", "text": "下一段"}
],
"format": {}
}保留当前素材的全部内容和用户指定顺序,不自动改写、删减或截断。标题或图文顺序确实无法判断时再询问。
若输入为 PPTX,先无损提取全部元素和原图:
python3 scripts/extract_ppt_content.py \
"/absolute/path/current.pptx" \
--output-dir "/absolute/path/current-assets"根据 manifest、幻灯片布局和当前指示生成 content_plan。manifest 只提供本次素材证据,不直接充当发布模板。
开始浏览器操作前确认:标题明确、所有正文已纳入、每张图片的绝对路径和自然尺寸已记录、顺序正确、没有上一篇残留。
2. 连接真实 Chrome
- 调用 Chrome MCP
list_pages。 - 选择已登录的
meiticaigou.com页面。 - 调用
take_snapshot获取当前 UID。 - 若连接、登录态或目标页面异常,立即报告,不降级到其他控制工具。
3. 打开空白稿
- 从当前页面侧栏“广告发布”的真实
href读取空白稿 URL,不硬编码账号参数。 - 仅在用户确认上一篇已提交或无需保留后导航到空白稿。
- 重新
take_snapshot,确认标题框、内容编辑器以及底部操作按钮存在。
4. 填标题并清空编辑器
- 用 Chrome MCP
fill写入content_plan.title。 - 找出边界框非零的可见
.edui-editor和其中尺寸最大的主内容 iframe,不依赖数字 ID。 - 把平台默认提示替换为空段落并触发
input。 - 确认默认说明和上一篇内容均已清除。
5. 严格逐张上传原图
按 content_plan.items 中图片首次出现的顺序处理:
- 用最新快照定位当前 UEditor 图片上传文件输入 UID。
- 调用一次 Chrome MCP
upload_file。 - 轮询主 iframe,直到对应
<img>同时满足:alt与当前本地文件名一致;src不含spacer.gif;naturalWidth、naturalHeight与本地原图一致。
- 只有三项全部通过才开始下一张。
不要只等待图片数量增加。若多个节点变成最后一张,清空全部错误图片并从第一张重新严格串行上传。
6. 按本次计划重建正文
- 收集上传成功的远端
<img>节点,并按文件名建立映射。 - 清空主 iframe。
- 严格遍历
content_plan.items:文本创建文本节点,图片移动对应远端节点;不得重排或遗漏。 - 优先落实用户格式;未指定时使用:
- 正文:
text-indent:2em; text-align:justify; line-height:1.85; margin:0 0 18px 0; - 图片段落:
text-align:center; margin:18px 0 24px 0; - 图片:
max-width:100%; height:auto; display:inline-block;
- 正文:
- 触发
input,滚动到正文开头,保持可视编辑模式。
具体 Chrome MCP/UEditor 代码见 references/site-playbook.md。
7. 全量核验
通过 Chrome MCP 读取实时 DOM,并逐项对照本次 content_plan:
- 标题完全一致;
- 每个文本项内容和顺序一致;
- 每张图片的文件名、顺序、远端 URL、自然尺寸一致;
- 默认提示、占位图和上一篇内容均不存在;
- 用户格式已经落实;
- 源码模式关闭;
- 页面仍处于用户授权阶段。
截取可视编辑器画面并把页面停在文章开头。向用户报告本次实际标题、正文长度、图片数量、尺寸校验和当前阶段。
8. 停在授权边界
默认停在排版核验完成处,不点击“预览”“保存”“下一步”,不选择媒体。用户明确要求继续时,只执行其授权的阶段;媒体不明确时先询问,最终提交或付款前遵守当次环境的确认要求。
备用逻辑
仅在 Chrome MCP 经过重新选页、重新快照和一次安全重试后仍失败,或 UEditor 关键控件没有可操作 DOM/UID 时启用:
- 明确记录触发备用逻辑的原因。
- 使用 Computer Use 控制当前已经登录的同一个 Chrome 窗口;不打开新配置、不安装扩展。
- 继续使用同一个
content_plan、原图列表和严格串行上传条件,不改变内容或顺序。 - 优先使用可访问元素,只有元素不可用时才依据最新截图进行坐标操作。
- 不使用系统剪贴板;文本通过安全输入,图片通过文件选择器逐张上传。
- 受阻步骤完成后立即尝试返回 Chrome MCP 做 DOM 与自然尺寸核验。
- Chrome MCP 始终无法恢复时,用 Computer Use 完成可视核验,并把无法验证的 DOM/远端尺寸项目明确报告为未验证,不能假装通过。
资源
scripts/extract_ppt_content.py:PPTX 全量文本、位置和原始图片无损提取。references/site-playbook.md:上述唯一 Chrome MCP 路径所需的 DOM 脚本。
interface: display_name: "媒体采购网发布" short_description: "优先Chrome MCP并在受阻时安全切换备用逻辑完成发布准备" default_prompt: "Use $meiticaigou-publisher to follow the Chrome MCP main path and use the documented fallback only when the main path is blocked."
媒体采购网唯一 Chrome MCP 路径
目录
- 识别可见 UEditor
- 清空默认内容
- 串行上传并等待完成
- 按当前计划重建正文
- 实时核验
本手册描述最优 Chrome MCP 主路径。只有主路径经过重新选页、重新快照和一次安全重试后仍受阻,才按 Skill 的“备用逻辑”使用同一真实 Chrome 的 Computer Use;不要使用扩展、临时浏览器或系统剪贴板。
1. 识别可见 UEditor
页面可能保留多个隐藏编辑器。每次导航后重新获取快照,再通过 evaluate_script 找到边界框非零的编辑器及其最大 iframe:
() => Array.from(document.querySelectorAll('.edui-editor')).map((editor) => {
const rect = editor.getBoundingClientRect();
const frames = Array.from(editor.querySelectorAll('iframe')).map((frame) => {
const r = frame.getBoundingClientRect();
return { id: frame.id, width: r.width, height: r.height };
});
return {
id: editor.id,
width: rect.width,
height: rect.height,
frames
};
})选择宽高大于 100 的 .edui-editor,再选择其中宽高最大的 iframe。不要依赖 #edui103、#ueditor_0 等数字 ID。
2. 清空默认内容
() => {
const editor = Array.from(document.querySelectorAll('.edui-editor')).find((node) => {
const r = node.getBoundingClientRect();
return r.width > 100 && r.height > 100;
});
const frame = Array.from(editor.querySelectorAll('iframe'))
.sort((a, b) => {
const ar = a.getBoundingClientRect();
const br = b.getBoundingClientRect();
return (br.width * br.height) - (ar.width * ar.height);
})[0];
const body = frame.contentDocument.body;
body.innerHTML = '<p><br></p>';
body.dispatchEvent(new InputEvent('input', {
bubbles: true,
inputType: 'insertText',
data: null
}));
return {
text: body.innerText,
images: body.querySelectorAll('img').length
};
}继续前确认正文为空、图片数为 0、平台默认提示不存在。
3. 串行上传并等待完成
从本次 content_plan 动态生成图片规格:
[
{"path": "/absolute/path/current.png", "filename": "current.png", "width": 1000, "height": 800}
]对每一项依次执行 upload_file,然后运行等待脚本。只有返回 ok: true 才处理下一项:
async () => {
const expectedIndex = CURRENT_ZERO_BASED_INDEX;
const expected = CURRENT_IMAGE_SPEC;
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
const editor = Array.from(document.querySelectorAll('.edui-editor')).find((node) => {
const r = node.getBoundingClientRect();
return r.width > 100 && r.height > 100;
});
const frame = Array.from(editor.querySelectorAll('iframe'))
.sort((a, b) => {
const ar = a.getBoundingClientRect();
const br = b.getBoundingClientRect();
return (br.width * br.height) - (ar.width * ar.height);
})[0];
const images = Array.from(frame.contentDocument.body.querySelectorAll('img'));
const image = images[expectedIndex];
const ready = image &&
image.alt === expected.filename &&
!image.src.includes('spacer.gif') &&
image.naturalWidth === expected.width &&
image.naturalHeight === expected.height;
if (ready) {
return {
ok: true,
count: images.length,
alt: image.alt,
src: image.src,
natural: [image.naturalWidth, image.naturalHeight]
};
}
await new Promise((resolve) => setTimeout(resolve, 400));
}
return { ok: false, reason: 'upload timeout or mismatch' };
}spacer.gif 表示仍在占位阶段,不能继续上传。若已发生覆盖,清空全部错误图并从第一张重新执行本节。
4. 按当前计划重建正文
把本次 content_plan 经过 JSON.stringify 后作为字面量嵌入函数源码,不要把任意字符串作为 Chrome MCP 元素 UID 参数。
() => {
const plan = CURRENT_CONTENT_PLAN;
const editor = Array.from(document.querySelectorAll('.edui-editor')).find((node) => {
const r = node.getBoundingClientRect();
return r.width > 100 && r.height > 100;
});
const frame = Array.from(editor.querySelectorAll('iframe'))
.sort((a, b) => {
const ar = a.getBoundingClientRect();
const br = b.getBoundingClientRect();
return (br.width * br.height) - (ar.width * ar.height);
})[0];
const doc = frame.contentDocument;
const body = doc.body;
const uploaded = new Map(
Array.from(body.querySelectorAll('img')).map((image) => [image.alt, image])
);
body.innerHTML = '';
for (const item of plan.items) {
if (item.type === 'text' || item.type === 'heading') {
const node = doc.createElement(item.type === 'heading' ? 'h3' : 'p');
node.textContent = item.text;
node.style.cssText = item.css || plan.format?.text ||
'text-indent:2em;text-align:justify;line-height:1.85;margin:0 0 18px 0;';
body.appendChild(node);
continue;
}
if (item.type === 'image') {
const filename = item.filename || item.path.split('/').pop();
const image = uploaded.get(filename);
if (!image) throw new Error(`Missing uploaded image: ${filename}`);
image.style.cssText = item.imageCss || plan.format?.image ||
'max-width:100%;height:auto;display:inline-block;';
const wrapper = doc.createElement('p');
wrapper.style.cssText = item.wrapperCss ||
'text-align:center;margin:18px 0 24px 0;';
wrapper.appendChild(image);
body.appendChild(wrapper);
continue;
}
throw new Error(`Unsupported content item: ${item.type}`);
}
body.dispatchEvent(new InputEvent('input', {
bubbles: true,
inputType: 'insertText',
data: null
}));
frame.contentWindow.scrollTo(0, 0);
return {
textItems: Array.from(body.children).filter((node) => !node.querySelector('img')).length,
images: body.querySelectorAll('img').length
};
}5. 实时核验
使用 Chrome MCP 读取当前标题和主 iframe DOM,返回:
{
title,
titleLength,
sourceMode,
bodyText,
childOrder,
boilerplateFound,
images: [
{ index, alt, src, natural: [width, height], style }
]
}逐项对照本次 content_plan。出现缺失、乱序、占位图、尺寸不符、默认提示、旧内容或源码模式开启时,不得宣布完成。修正后重新执行完整核验。
#!/usr/bin/env python3 """Extract every usable PPTX text element and original picture without imposing article structure."""
from future import annotations
import argparse import hashlib import json from pathlib import Path from typing import Any, Iterable, Iterator
from PIL import Image from pptx import Presentation from pptx.enum.shapes import MSO_SHAPE_TYPE
def iter_shapes(shapes: Iterable[Any], path: tuple[int, ...] = ()) -> Iterator[tuple[Any, tuple[int, ...]]]: for index, shape in enumerate(shapes): z_path = path + (index,) if shape.shape_type == MSO_SHAPE_TYPE.GROUP: yield from iter_shapes(shape.shapes, z_path) else: yield shape, z_path
def box(shape: Any) -> dict[str, int]: return { "left": int(getattr(shape, "left", 0)), "top": int(getattr(shape, "top", 0)), "width": int(getattr(shape, "width", 0)), "height": int(getattr(shape, "height", 0)), }
def max_font_size_pt(paragraph: Any) -> float | None: sizes = [run.font.size.pt for run in paragraph.runs if run.font.size is not None] return max(sizes) if sizes else None
def text_elements(shape: Any, slide_number: int, z_path: tuple[int, ...]) -> list[dict[str, Any]]: elements: list[dict[str, Any]] = [] shape_box = box(shape)
if getattr(shape, "has_text_frame", False):
for paragraph_index, paragraph in enumerate(shape.text_frame.paragraphs):
text = "".join(run.text for run in paragraph.runs).strip() or paragraph.text.strip()
if not text:
continue
elements.append(
{
"type": "text",
"source_type": "text_frame",
"slide": slide_number,
"shape_name": shape.name,
"paragraph_index": paragraph_index,
"text": text,
"max_font_size_pt": max_font_size_pt(paragraph),
"box": shape_box,
"z_path": list(z_path),
}
)
if getattr(shape, "has_table", False):
for row_index, row in enumerate(shape.table.rows):
values = [cell.text.strip() for cell in row.cells]
if not any(values):
continue
elements.append(
{
"type": "text",
"source_type": "table_row",
"slide": slide_number,
"shape_name": shape.name,
"row_index": row_index,
"text": "\t".join(values),
"max_font_size_pt": None,
"box": shape_box,
"z_path": list(z_path),
}
)
return elementsdef image_dimensions(path: Path) -> tuple[int, int]: with Image.open(path) as image: return image.size
def reading_key(element: dict[str, Any]) -> tuple[Any, ...]: position = element["box"] return ( element["slide"], position["top"], position["left"], tuple(element["z_path"]), element.get("paragraph_index", element.get("row_index", 0)), )
def notes_text(slide: Any) -> list[str]: if not getattr(slide, "has_notes_slide", False): return [] frame = slide.notes_slide.notes_text_frame return [paragraph.text.strip() for paragraph in frame.paragraphs if paragraph.text.strip()]
def extract(pptx_path: Path, output_dir: Path) -> dict[str, Any]: presentation = Presentation(str(pptx_path)) output_dir.mkdir(parents=True, exist_ok=True)
slides: list[dict[str, Any]] = []
images: list[dict[str, Any]] = []
all_elements: list[dict[str, Any]] = []
image_index = 0
text_index = 0
for slide_number, slide in enumerate(presentation.slides, start=1):
slide_elements: list[dict[str, Any]] = []
for shape, z_path in iter_shapes(slide.shapes):
for element in text_elements(shape, slide_number, z_path):
text_index += 1
element["id"] = f"text-{text_index}"
slide_elements.append(element)
if shape.shape_type != MSO_SHAPE_TYPE.PICTURE:
continue
image_index += 1
extension = shape.image.ext.lower()
if extension == "jpg":
extension = "jpeg"
filename = f"image{image_index}.{extension}"
destination = output_dir / filename
blob = shape.image.blob
destination.write_bytes(blob)
width, height = image_dimensions(destination)
image_record = {
"id": f"image-{image_index}",
"type": "image",
"slide": slide_number,
"shape_name": shape.name,
"filename": filename,
"path": str(destination.resolve()),
"extension": extension,
"width": width,
"height": height,
"bytes": len(blob),
"sha256": hashlib.sha256(blob).hexdigest(),
"box": box(shape),
"z_path": list(z_path),
}
images.append(image_record)
slide_elements.append(image_record.copy())
source_order = sorted(slide_elements, key=lambda item: tuple(item["z_path"]))
reading_order = sorted(slide_elements, key=reading_key)
all_elements.extend(reading_order)
slides.append(
{
"slide": slide_number,
"notes": notes_text(slide),
"source_order": source_order,
"reading_order": reading_order,
}
)
first_slide_texts = [
element for element in slides[0]["reading_order"] if element["type"] == "text"
] if slides else []
title_candidates = [
{
"id": element["id"],
"text": element["text"],
"max_font_size_pt": element["max_font_size_pt"],
"box": element["box"],
}
for element in sorted(
first_slide_texts,
key=lambda item: (
-(item["max_font_size_pt"] or 0),
item["box"]["top"],
item["box"]["left"],
),
)
]
manifest: dict[str, Any] = {
"source": str(pptx_path.resolve()),
"slide_count": len(slides),
"text_element_count": text_index,
"image_count": len(images),
"title_candidates": title_candidates,
"slides": slides,
"images": images,
"reading_sequence": all_elements,
"instructions": (
"Do not publish this sequence blindly. Build a fresh content_plan from the "
"current user request, title candidates, slide layout, and all extracted elements."
),
}
manifest_path = output_dir / "manifest.json"
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
return manifestdef parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Extract all PPTX content and original pictures without choosing article structure." ) parser.add_argument("pptx", type=Path, help="Source .pptx file") parser.add_argument("--output-dir", type=Path, required=True) return parser.parse_args()
def main() -> None: args = parse_args() if not args.pptx.is_file(): raise SystemExit(f"PPTX not found: {args.pptx}") if args.pptx.suffix.lower() != ".pptx": raise SystemExit("Only .pptx files are supported")
manifest = extract(args.pptx, args.output_dir)
print(
json.dumps(
{
"manifest": str((args.output_dir / "manifest.json").resolve()),
"slides": manifest["slide_count"],
"text_elements": manifest["text_element_count"],
"images": manifest["image_count"],
"title_candidates": len(manifest["title_candidates"]),
},
ensure_ascii=False,
)
)if name == "main": main()