diff --git a/new-media-platform/website-new/rewrite/auto_state.json b/new-media-platform/website-new/rewrite/auto_state.json index 761707a..370332b 100644 --- a/new-media-platform/website-new/rewrite/auto_state.json +++ b/new-media-platform/website-new/rewrite/auto_state.json @@ -1,12 +1,6 @@ { - "date": "2026-06-22", - "fulfilled_slots": [ - "08:00" - ], - "published_ids": [ - 4011 - ], - "used_columns": [ - "糖友" - ] + "date": "2026-07-01", + "fulfilled_slots": [], + "published_ids": [], + "used_columns": [] } \ No newline at end of file diff --git a/new-media-platform/website-new/rewrite/cms_ops.py b/new-media-platform/website-new/rewrite/cms_ops.py index f2147db..72546a7 100644 --- a/new-media-platform/website-new/rewrite/cms_ops.py +++ b/new-media-platform/website-new/rewrite/cms_ops.py @@ -198,6 +198,74 @@ def update_article_content( return affected > 0 +def _cleanup_cover_file(cover_image: str): + """ + 删除封面图文件及对应缩略图(与后台 file_utils.cleanup_file_by_path 逻辑对齐)。 + + Args: + cover_image: 封面图路径(/storage/admin/images/... 或相对路径) + """ + if not cover_image or cover_image.startswith(("http://", "https://")): + return + + upload_dir = os.path.join(PROJECT_ROOT, "storage", "admin", "images") + thumbnail_dir = os.path.join(upload_dir, "thumbnails") + + # 兼容两种路径格式 + if cover_image.startswith("/storage/admin/images/"): + rel_path = cover_image[len("/storage/admin/images/"):] + else: + rel_path = cover_image.lstrip("/") + + full_path = os.path.join(upload_dir, rel_path) + + # 删除原文件 + try: + if os.path.isfile(full_path): + os.remove(full_path) + except OSError: + pass + + # 同步删除缩略图(与原图同名,扩展名 .jpg) + try: + stem = os.path.splitext(os.path.basename(rel_path))[0] + thumb_path = os.path.join(thumbnail_dir, f"{stem}.jpg") + if os.path.isfile(thumb_path): + os.remove(thumb_path) + except OSError: + pass + + +def delete_article(article_id: int) -> bool: + """ + 删除文章(物理删除),同步清理封面图片文件及缩略图。 + 与后台 routers/articles.py 的 delete_article 行为对齐。 + + Args: + article_id: 文章 ID + + Returns: + 是否删除成功 + """ + conn = _get_conn() + cursor = conn.cursor() + + # 先查 cover_image 以便删除后清理文件 + cursor.execute("SELECT cover_image FROM articles WHERE id = ?", (article_id,)) + row = cursor.fetchone() + cover_image = row["cover_image"] if row else None + + cursor.execute("DELETE FROM articles WHERE id = ?", (article_id,)) + conn.commit() + affected = cursor.rowcount + conn.close() + + if affected > 0 and cover_image: + _cleanup_cover_file(cover_image) + + return affected > 0 + + def count_drafts() -> int: """统计草稿文章数量""" conn = _get_conn() diff --git a/new-media-platform/website-new/rewrite/pipeline.py b/new-media-platform/website-new/rewrite/pipeline.py index e85315c..5f47437 100644 --- a/new-media-platform/website-new/rewrite/pipeline.py +++ b/new-media-platform/website-new/rewrite/pipeline.py @@ -33,7 +33,7 @@ BAOYU_DIR = "/home/ubuntu/.nanobot/workspace/skills/baoyu-post-to-wechat" sys.path.insert(0, BAOYU_DIR) sys.path.insert(0, SCRIPT_DIR) -from cms_ops import get_article_full, update_article_content +from cms_ops import get_article_full, update_article_content, delete_article from baoyu_optimized import WeChatPublisher # ---------- 配置 ---------- @@ -76,6 +76,8 @@ IMAGE_PROMPT_SYSTEM = """你是一个图片提示词专家。根据下面给出 HUNYUAN_API_KEY = "sk-qgPNJwuOGji7I7ixqNbbkWaTIvF1MrYTRWQnEZ3ZRVU4u7y3" HUNYUAN_API_BASE = "https://tokenhub.tencentmaas.com/v1/api/image" HUNYUAN_MODEL = "hy-image-v3.0" +HUNYUAN_LITE_MODEL = "hy-image-lite" +HUNYUAN_LITE_ENDPOINT = "https://tokenhub.tencentmaas.com/v1/api/image/lite" # Seedream(降级) SEEDREAM_API_KEY = "83a29955-c633-449f-97f1-f49b62295cf6" @@ -297,10 +299,10 @@ def _strip_images(markdown: str) -> str: return re.sub(r'!\[.*?\]\([^)]+\)', '', markdown) -def _check_original_content(content: str) -> int: +def _check_original_content(content: str, article_id: int) -> int: """ 检查原文是否有足够的文字内容。 - 返回纯文本字符数;低于 _MIN_TEXT_LENGTH 抛出 ValueError。 + 返回纯文本字符数;低于 _MIN_TEXT_LENGTH 删除文章并抛出 ValueError。 """ text = _strip_images(content) # 也移除 markdown 链接、HTML 标签等 @@ -309,27 +311,33 @@ def _check_original_content(content: str) -> int: text = re.sub(r'\s+', ' ', text).strip() text_len = len(text) if text_len < _MIN_TEXT_LENGTH: + delete_article(article_id) + print(f" 🗑️ 已删除短文章 ID={article_id}({text_len} 字符)", file=sys.stderr) raise ValueError( f"原文文字内容不足({text_len} 字符 < {_MIN_TEXT_LENGTH})," - f"可能全部为图片,跳过改写" + f"可能全部为图片,已删除" ) return text_len -def _check_llm_response(content: str) -> None: +def _check_llm_response(content: str, article_id: int) -> None: """ 检查 LLM 返回是否是无意义的占位回复。 如果是占位回复则抛出 ValueError。 """ for pattern in _LLM_PLACEHOLDER_PATTERNS: if re.search(pattern, content): + delete_article(article_id) + print(f" 🗑️ 已删除占位回复文章 ID={article_id}", file=sys.stderr) raise ValueError( - f"LLM 返回疑似占位回复(匹配模式: {pattern}),不发布" + f"LLM 返回疑似占位回复(匹配模式: {pattern}),已删除" ) # 额外检查:改写后内容过短(< 200 字)也视为无效 if len(content) < _MIN_TEXT_LENGTH: + delete_article(article_id) + print(f" 🗑️ 已删除改写后过短文章 ID={article_id}({len(content)} 字符)", file=sys.stderr) raise ValueError( - f"改写后内容过短({len(content)} 字符 < {_MIN_TEXT_LENGTH}),不发布" + f"改写后内容过短({len(content)} 字符 < {_MIN_TEXT_LENGTH}),已删除" ) @@ -374,12 +382,73 @@ def _make_image_prompt(context_text: str, api_key: str) -> str: return "温暖的医疗健康主题插图,简洁专业的扁平设计风格" +def _hunyuan_lite_image(prompt: str, output_path: str, + resolution: str = "1024:1024") -> bool: + """腾讯混元生图 hy-image-lite (轻量同步版) + + POST 一次直接返回图片 URL,无需轮询。价格更低。 + 注意:Lite 固定输出 1024×1024,不支持自定义分辨率。 + """ + import requests + + if not HUNYUAN_API_KEY: + return False + + try: + payload = { + "model": HUNYUAN_LITE_MODEL, + "prompt": prompt, + "rsp_img_type": "url", + } + + headers = { + "Authorization": f"Bearer {HUNYUAN_API_KEY}", + "Content-Type": "application/json", + } + + resp = requests.post(HUNYUAN_LITE_ENDPOINT, json=payload, + headers=headers, timeout=60) + resp.raise_for_status() + result = resp.json() + + data = result.get("data", []) + if not data: + print(f" ⚠️ 混元 Lite 无数据: {result}", file=sys.stderr) + return False + + img_url = data[0].get("url", "") + if not img_url: + print(f" ⚠️ 混元 Lite 无 URL: {data[0]}", file=sys.stderr) + return False + + r = requests.get(img_url, timeout=60) + r.raise_for_status() + img_data = r.content + + os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) + with open(output_path, "wb") as f: + f.write(img_data) + print(f" ✅ 混元 Lite: {os.path.basename(output_path)} ({len(img_data)} bytes)", file=sys.stderr) + return True + + except Exception as e: + print(f" ⚠️ 混元 Lite 异常: {e}", file=sys.stderr) + return False + + def _generate_image(prompt: str, output_path: str, resolution: str = "1024:1024") -> bool: - """调用混元生图生成图片,失败则降级到 Seedream""" + """生成封面配图:优先混元 Lite → 混元 V3.0 → Seedream""" + # 1) 混元 Lite (轻量版,更便宜,同步返回) + success = _hunyuan_lite_image(prompt, output_path, resolution) + if success: + return True + # 2) 混元 V3.0 (标准版) + print(" ↪ 降级到混元 V3.0...", file=sys.stderr) success = _hunyuan_image(prompt, output_path, resolution) if success: return True + # 3) Seedream print(" ↪ 降级到 Seedream...", file=sys.stderr) return _seedream_image(prompt, output_path, resolution) @@ -806,13 +875,13 @@ def run_pipeline( new_content = rewritten_content else: # 自动改写前先检查原文内容质量 - text_len = _check_original_content(original) + text_len = _check_original_content(original, article_id) print(f" 原文有效文字: {text_len} chars", file=sys.stderr) print(" 模式: 自动改写(DeepSeek)", file=sys.stderr) api_key = load_deepseek_key() new_content = call_deepseek(original, api_key) # 检查 LLM 返回是否有效 - _check_llm_response(new_content) + _check_llm_response(new_content, article_id) print(f" 改写后字数: {len(new_content)} chars", file=sys.stderr) diff --git a/skills/selfhosted-map-tiles/SKILL.md b/skills/selfhosted-map-tiles/SKILL.md new file mode 100644 index 0000000..3250a44 --- /dev/null +++ b/skills/selfhosted-map-tiles/SKILL.md @@ -0,0 +1,45 @@ +--- +name: selfhosted-map-tiles +description: Self-hosted vector tile map services using GeoLibre and OpenFreeMap. Use when deploying map tile infrastructure, configuring basemaps for GIS applications, or troubleshooting map tile access from China (GFW issues). Covers Docker deployment, basemap configuration, and China-accessible alternatives. +--- + +# Self-Hosted Map Tiles + +GeoLibre + OpenFreeMap provide a self-hosted vector tile mapping stack based on OpenStreetMap data. + +## Quick Deploy + +```bash +docker pull ghcr.io/opengeos/geolibre:latest +docker run -d -p 8080:80 ghcr.io/opengeos/geolibre:latest +``` + +GeoLibre container includes a built-in Python sidecar and nginx reverse proxy serving everything on a unified domain. No additional CORS configuration is needed. + +## OpenFreeMap as Basemap + +GeoLibre ships with OpenFreeMap as a built-in basemap — works out of the box. + +- OpenFreeMap: free vector tile service based on OpenStreetMap +- No registration, API key, or rate limits +- Can be self-hosted or use the public instance +- Self-hosting requires substantial disk space (global tiles ~hundreds of GB) + +### GFW Consideration + +OpenFreeMap's public instance is hosted in Hungary. Access from mainland China may be unstable due to the Great Firewall. + +### China-Accessible Alternatives + +When OpenFreeMap is not directly reachable, configure GeoLibre to use domestically available XYZ Tile basemaps: + +- **天地图** (Tianditu): China's national mapping service +- **高德地图** (Amap/Gaode): Commercial map provider with tile access + +Replace the default tile source in GeoLibre configuration with one of the above. Both provide XYZ tile endpoints accessible within China without GFW issues. + +## Use Cases + +- Web GIS applications that need offline-capable or self-hosted basemaps +- Applications serving Chinese users where foreign tile services are unreliable +- Projects requiring no external API key or rate limit dependency