← 返回列表 gk_auto.php 47.1 KB · 2026-11-06 10:19
<?php
// ===== 源码查看保护 =====
// 本文件同时用于服务器运行(cron)和在线查看源码。
// 通过 Web 直接访问时,仅以纯文本输出自身源码,不执行任何业务逻辑。
// 注意:gk_auto.php 若被 Web 执行会真的触发发文,这层保护是必需的。
if (PHP_SAPI !== 'cli' && basename($_SERVER['SCRIPT_FILENAME'] ?? '') === basename(__FILE__)) {
    header('Content-Type: text/plain; charset=utf-8');
    echo file_get_contents(__FILE__);
    exit;
}
/**
 * gk_auto.php — 三站每日 AI 自动发文
 *
 * 用法:
 *   php gk_auto.php main        只发主站
 *   php gk_auto.php all         依次发三站
 *   php gk_auto.php main --force 忽略当天去重,强制发
 *   php gk_auto.php --test-ai   仅测试 AI 连通性(不写库)
 *
 * 设计要点:
 *   1. AI key 缺失 → 安全跳过,写日志,不抛异常
 *   2. 每天每站最多一篇(按当天日期去重,postmeta 标记 _gk_auto_date)
 *   3. 直连数据库发布,不走 XML-RPC(主站与 v 站的 XML-RPC 已被 nginx 断连)
 *   4. 分类按 slug 反查 term_taxonomy_id,不硬编码 ID(避免分类重建后失效)
 *   5. 全文不入 _transient 缓存,发布后清理站点瞬态
 */

if (PHP_SAPI !== 'cli') { die("CLI only\n"); }
mb_internal_encoding('UTF-8');

$cfg = require __DIR__ . '/config.php';
if (!is_array($cfg) || empty($cfg['sites'])) {
    fwrite(STDERR, "配置文件加载失败或为空: " . __DIR__ . "/config.php\n");
    exit(1);
}
require_once __DIR__ . '/hotspots.php';
require_once __DIR__ . '/baidu_push.php';
require_once __DIR__ . '/media.php';

$SCRIPT_START = microtime(true);

function logline($msg) {
    $ts = date('Y-m-d H:i:s');
    fwrite(STDOUT, "[$ts] $msg\n");
}

/* =========================================================
 * 1. AI 调用
 * =======================================================*/

/**
 * 从模型返回中抠出 JSON(容忍 ```json 包裹、前后废话)
 */
function extract_json($text) {
    $text = trim($text);
    // 去掉 markdown 代码围栏
    if (preg_match('/```(?:json)?\s*(.+?)\s*```/s', $text, $m)) {
        $text = trim($m[1]);
    }
    // 直接找最外层 {...}
    $start = strpos($text, '{');
    $end   = strrpos($text, '}');
    if ($start === false || $end === false || $end <= $start) {
        return null;
    }
    $json = substr($text, $start, $end - $start + 1);
    $obj = json_decode($json, true);
    return is_array($obj) ? $obj : null;
}

function ai_chat($prompt, $system, $cfg) {
    $lastErr = '';
    foreach ($cfg['ai']['providers'] as $p) {
        $key = trim($p['key']);
        if ($key === '') { continue; }

        for ($try = 1; $try <= $cfg['ai']['retry']; $try++) {
            $payload = json_encode([
                'model'       => $p['model'],
                'messages'    => [
                    ['role' => 'system', 'content' => $system],
                    ['role' => 'user',   'content' => $prompt],
                ],
                'temperature' => $cfg['ai']['temperature'],
                'max_tokens'  => $cfg['ai']['max_tokens'],
                'stream'      => false,
            ], JSON_UNESCAPED_UNICODE);

            $ch = curl_init($p['url']);
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_POST           => true,
                CURLOPT_POSTFIELDS     => $payload,
                CURLOPT_TIMEOUT        => $cfg['ai']['timeout'],
                CURLOPT_CONNECTTIMEOUT => 20,
                CURLOPT_SSL_VERIFYPEER => true,
                CURLOPT_SSL_VERIFYHOST => 2,
                CURLOPT_HTTPHEADER     => [
                    'Content-Type: application/json',
                    'Authorization: Bearer ' . $key,
                    'User-Agent: gk-auto/1.0',
                ],
            ]);
            $body = curl_exec($ch);
            $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            $cerr = curl_error($ch);
            curl_close($ch);

            if ($body === false) {
                $lastErr = "[{$p['name']}] curl: $cerr";
                logline("  AI 网络错误,尝试重试($try): $lastErr");
                sleep(3);
                continue;
            }

            if ($code !== 200) {
                $snippet = mb_substr($body, 0, 300);
                $lastErr = "[{$p['name']}] HTTP $code: $snippet";
                // 401/402/429 是账号/额度问题,重试无意义,直接换下一个 provider
                if (in_array($code, [401, 402, 403, 429], true)) {
                    logline("  $lastErr → 跳过该 provider");
                    break;
                }
                logline("  AI 返回异常,尝试重试($try): $lastErr");
                sleep(3);
                continue;
            }

            $j = json_decode($body, true);
            $content = $j['choices'][0]['message']['content'] ?? '';
            if ($content === '') {
                $lastErr = "[{$p['name']}] 返回空内容";
                logline("  $lastErr,重试($try)");
                sleep(2);
                continue;
            }
            logline("  AI 生成成功 via {$p['name']}/{$p['model']} (" . mb_strlen($content) . " 字符)");
            return $content;
        }
    }
    return "AI_CALL_FAILED::" . $lastErr;
}

/* =========================================================
 * 2. 提示词
 * =======================================================*/

function build_prompt($siteKey, $site, $today, $recentTitles, $hotBlock = '', $seq = 1, $total = 1, $assignedCat = null) {
    $min = $site['words'][0];
    $max = $site['words'][1];

    // 多篇连发时,明确要求选题错开
    if ($total > 1) {
        $hotBlock .= "\n【重要】本次要连发 {$total} 篇文章,你负责其中第 {$seq} 篇。\n"
            . "必须选一个与同批其他文章**完全不同**的角度、领域或影片,绝不能撞题、绝不能是同一话题的换皮版本。\n";
        $extra = '';
        foreach (($GLOBALS['gk_batch_titles'] ?? []) as $bt) {
            $extra .= '  - ' . $bt . "\n";
        }
        if ($extra !== '') {
            $hotBlock .= "【本批次已经发掉的主题,绝对不要再碰】\n" . $extra . "\n";
        }
    }

    $catLines = [];
    foreach ($site['categories'] as $slug => $name) {
        $catLines[] = "  - $slug  →  $name";
    }
    $catBlock = implode("\n", $catLines);

    $recentBlock = '';
    if ($recentTitles) {
        $lines = array_map(function ($t) { return '  - ' . $t; }, array_slice($recentTitles, 0, 25));
        $recentBlock = "\n【近期已发标题(必须避开这些主题,勿重复)】\n" . implode("\n", $lines) . "\n";
    }

    $dateCn = date('n月j日', strtotime($today));

    // 指定的栏目(轮转分配);有值时强制按该栏目写,AI 不再自选分类
    $forcedSlug = '';
    $forcedName = '';
    if (is_array($assignedCat) && !empty($assignedCat['slug'])) {
        $forcedSlug = $assignedCat['slug'];
        $forcedName = $assignedCat['name'] ?? ($site['categories'][$forcedSlug] ?? $forcedSlug);
    }
    // 该栏目的选题方向池
    $topicHint = '';
    if ($forcedSlug !== '' && !empty($site['topic_hints'][$forcedSlug])) {
        $topicHint = $site['topic_hints'][$forcedSlug];
    }

    // 指定栏目时,用"栏目锁定"块替换原来的"从分类清单里挑一个"
    $catLockBlock = '';
    if ($forcedSlug !== '') {
        $catLockBlock = "【栏目锁定(必须遵守)】\n"
            . "本篇文章**已经确定**归入栏目「{$forcedName}」(slug: {$forcedSlug})。\n"
            . "你必须在这个栏目的范围内选题,不要写别的话题。\n"
            . ($topicHint !== '' ? "该栏目的选题方向包括:{$topicHint}。\n" : '')
            . "请从上述方向中**自行挑一个具体角度**,不要写成栏目综述。\n"
            . "category 字段必须固定返回 \"{$forcedSlug}\"。\n";

        // 热点素材与栏目方向常常冲突(如「原创文学」遇上百度热搜),
        // 除"热点侃点"外一律降级为可选参考,避免两条指令打架。
        $hotBlock = str_replace(
            '请从以下真实发生的热点中**选定一个**作为文章切入点。',
            ($forcedSlug === 'redian'
                ? '本栏目专写热点,请**优先**从以下热点中选定一个作为切入点。'
                : '以下热点**仅供参考**:若与本次栏目主题不符,请完全忽略,按栏目方向自行选题。'),
            $hotBlock
        );
    }

    // 医疗医药类选题禁令(三站通用)
    $medBan = "【绝对禁止的选题(硬性要求,优先于其他所有要求)】\n"
        . "不要写任何医疗、医药、疾病、诊疗、药品、健康养生相关的主题。\n"
        . "包括但不限于:疾病防治与症状科普、医院与医生、药物与疫苗、手术与治疗、\n"
        . "体检筛查与诊断、养生保健与保健品、医疗政策与药价、医疗器械、心理健康疾病。\n"
        . "如果热点素材、影片或选题方向里出现这类话题,直接跳过,改选其他主题。\n"
        . "注意区分:写「电脑病毒」「数据移植」「系统诊断」这类技术话题是允许的,\n"
        . "但不要借这些词写到人体健康与疾病。\n\n";

    // ---- 各站差异化提示词 ----
    switch ($siteKey) {

        case 'main':
            $system = "你是一位中文资深撰稿人,写作风格沉稳、有知识密度、有独立见解,擅长把复杂话题讲得清晰可读。你写的文章会直接发布在一个运营十余年的中文独立博客上,读者多为技术与人文领域的成年读者。你拒绝空话套话、AI 味明显的排比与空洞总结。";

            // 有指定栏目时,选题范围收窄到该栏目
            $topicScope = $forcedSlug !== ''
                ? $catLockBlock
                : "【分类要求】\n从下面分类中选**恰好一个**最贴合的,并在 JSON 的 category 字段返回它的 slug:\n{$catBlock}";

            $task = <<<TXT
今天是 {$today}({$dateCn})。请为中文博客「飞鸿博客」撰写 **一篇** 原创文章。

【选题要求】
{$medBan}{$hotBlock}
{$topicScope}
1. 必须有**具体的切入角度**,不要写"浅谈XX"这类空泛题目。举例说明什么是好的角度:
   - 好:《被遗忘的拨号网络:拨号上网那些年,我们到底在 dial 什么》
   - 好:《当胶片颗粒成为审美:数字时代为什么有人开始"故意做旧"》
   - 差:《浅谈科技的发展》《关于摄影的一些思考》
2. 标题要求:18 字以内,具体、有信息量或好奇心钩子,不要用"浅谈/浅议/初探/浅析"开头,不要加书名号以外的标点堆砌。
3. 选题不得与近期已发文章重复(见下方列表)。
{$recentBlock}
【正文要求】
1. 字数 {$min}–{$max} 字(中文字符计)。
2. 结构:开头 2–3 段直接切入主题,不要"在当今这个时代"这类口水开场;中部用二级标题分节(3–5 个 ## 小节);结尾给出有信息量的收束,不要空泛抒情。
3. 事实必须准确。涉及具体年份、数据、人名、书名时宁可不写,也不要编造。不确定的数字用"大约/据估计"表述。
4. 允许并鼓励出现具体细节:年份、书名、型号、场景描写、个人经验。
5. 绝对禁止:Markdown 语法(##、**、- 列表符)、"综上所述"、"让我们一起"、"在这个信息爆炸的时代"、"总而言之"、编造的引用与出处、"希望这篇文章能帮到你"。
6. 使用 HTML 标签:段落 <p>、小节标题 <h2>、强调 <strong>、引文 <blockquote>。不要用 <script>、<iframe>、<img>。

【输出格式】
只输出一个 JSON 对象,不要任何额外说明文字:
{"title":"文章标题","category":"{$forcedSlug}","excerpt":"80字以内的摘要","content":"<p>正文第一段</p><h2>小节标题</h2><p>...</p>"}
TXT;
            break;

        case 'film':
            $system = "你是一位资深影评人,笔触克制、洞察专业,既能从叙事、表演、视听语言、主题意蕴多个层面拆解影片,也敢于给出明确的褒贬立场。你拒绝豆瓣式短评的碎片化,也拒绝学院派论文的术语堆砌。你写的文章会发布在一个专注影评的中文站。";
            $task = <<<TXT
今天是 {$today}({$dateCn})。请为中文影评站「飞飞影评」撰写 **一篇** 原创影评。

【影片要求】
{$medBan}{$hotBlock}
1. 选择一部**真实存在**且有足够公共资料可考的电影(经典外语片、国产片、纪录片、高分剧集均可)。中文片名必须准确。
2. **优先从上方的热点素材中选择一部正在上映或正在热映的影片**——这样文章有现实时效性,更容易被读者和搜索引擎接纳。
3. 如果你不能确信影片的导演、上映年份、奖项等基本信息,请换一部你有把握的影片。**宁可写冷门但确定的作品,也不要写热门却可能记错的。**
4. 不得与近期已发文章重复。
{$recentBlock}
【分类要求】
从下面分类中选**恰好一个**最贴合的,并在 JSON 的 category 字段返回它的 slug:
{$catBlock}
(国外影片→guowai,国产影片→guonei,经典重读/合集专题→zhuanti,剧集→dianshiju,行业观察→meiti)

【正文要求】
1. 字数 {$min}–{$max} 字(中文字符计)。
2. 结构建议:小标题分段(3–5 个 ## 小节)。建议维度——影片简介与背景(1 段即可,不要写成百科词条)、叙事结构与剪辑、表演与人物、视听语言(摄影/美术/配乐)、主题解读、你的评价。
3. **必须给出明确立场**:喜欢什么、不喜欢什么、为什么。不要写"总体而言该片值得一看"这类和稀泥的结论。
4. 事实准确。不确定的年份、奖项宁可略去不写。禁止编造影评人引语、票房数字、颁奖记录。
5. 绝对禁止:Markdown 语法(##、**、- 列表符)、"这部电影yor值得一看"、"综上所述"、"让我们一起走进"、"一部佳作"这类空泛赞美、编造的引用与出处。
6. 使用 HTML 标签:段落 <p>、小节标题 <h2>、强调 <strong>、引文 <blockquote>。不要用 <script>、<iframe>、<img>。

【输出格式】
只输出一个 JSON 对象,不要任何额外说明文字:
{"title":"文章标题(可含《片名》)","category":"分类slug","excerpt":"80字以内的摘要","content":"<p>正文第一段</p><h2>小节标题</h2><p>...</p>"}
TXT;
            break;

        case 'visual':
            $system = "你是一位资深摄影技师兼器材评测作者,同时是一名常年带团的摄影师。你写的器材分析以实测逻辑为基础,能把参数讲成人话,也愿意讲清楚不同器材在不同场景下的取舍。你拒绝器材软文式的参数复读,也拒绝故弄玄虚的玄学表述。";
            $task = <<<TXT
今天是 {$today}({$dateCn})。请为中文摄影站「飞飞视觉」的**摄影博文**栏目撰写 **一篇** 原创技术文章。

【选题要求】
{$medBan}{$hotBlock}
从以下方向任选其一,必须足够具体:
- 摄影技术原理讲解(如:景深与背景虚化的真实边界、测光为什么常常骗你、色彩空间与色彩管理链条、手持防抖的真实效果)
- 实器材分析对比(如:不同焦段、不同光圈、不同画幅系统的取舍)
- 实战拍摄经验与解决方案(如:弱光如何布光、如何在强反差下保住高光、暗光下的噪点与后期策略)
- 后期流程(Raw 处理的正确顺序、局部调整思路)
2. 标题要求:18 字以内,具体、有技术含量。举例说明什么是好的角度:
   - 好:《光圈不是万能:拍背景虚化前你必须搞懂的五件事》
   - 好:《阴天不是坏天气:云层柔光下的高反差控制》
   - 差:《摄影基础知识分享》《关于镜头的一些心得》
3. 不得与近期已发文章重复(见下方列表)。
{$recentBlock}
【分类要求】
本栏目固定为 zhaji(摄影博文),category 字段固定返回 "zhaji"。

【正文要求】
1. 字数 {$min}–{$max} 字(中文字符计)。
2. 结构:开头直接抛出问题场景;中部用二级标题分节(3–5 个 ## 小节),每节解决一个具体疑问;结尾给出可操作的建议清单或决策思路。
3. **必须给出可操作的量化信息**:光圈 f 值、快门速度、ISO 范围、像素级容差、动态范围 EV 数、重量与尺寸对比等。有具体数字的文章才有价值。
4. 器材提及要克制,只在论证必要时提及型号,不要写成产品推荐。
5. 事实必须准确。参数数字宁可不写具体值,也不要编造。避免绝对化表述("一定""绝对""永远")。
6. 绝对禁止:Markdown 语法(##、**、- 列表符)、"摄影的魅力在于"、"每一个热爱摄影的人"、"希望对大家有帮助"、编造的测试数据。
7. 使用 HTML 标签:段落 <p>、小节标题 <h2>、小标题 <h3>、强调 <strong>、引文 <blockquote>。不要用 <script>、<iframe>、<img>。

【输出格式】
只输出一个 JSON 对象,不要任何额外说明文字:
{"title":"文章标题","category":"zhaji","excerpt":"80字以内的摘要","content":"<p>正文第一段</p><h2>小节标题</h2><p>...</p>"}
TXT;
            break;

        default:
            $task = "请写一篇原创中文文章。";
    }

    return ['system' => $system, 'task' => $task];
}

/* =========================================================
 * 3. 数据库工具
 * =======================================================*/

function db_connect($conf) {
    [$host, $user, $pass, $dbname] = $conf['db'];
    mysqli_report(MYSQLI_REPORT_OFF);
    $conn = @new mysqli($host, $user, $pass, $dbname);
    if ($conn->connect_errno) {
        throw new Exception("MySQL 连接失败: " . $conn->connect_error);
    }
    $conn->set_charset('utf8mb4');
    return $conn;
}

/** 取近期已发标题,避免选题重复 */
function recent_titles($conn, $prefix, $limit = 25) {
    $sql = "SELECT post_title FROM {$prefix}posts
            WHERE post_type='post' AND post_status='publish'
            ORDER BY ID DESC LIMIT $limit";
    $res = $conn->query($sql);
    $out = [];
    if ($res) { while ($r = $res->fetch_assoc()) { $out[] = $r['post_title']; } }
    return $out;
}

/**
 * 统计当天已发布数量
 * $mode = 'auto'   只数自动任务发的(cron),手动发布的不算
 * $mode = 'manual' 只数手动发的
 * $mode = 'any'    全部
 *
 * 分开记账的意义:手动额外发布不会被自动任务当成"已完成",
 * 两者互不干涉。
 */
function already_published_today($conn, $prefix, $dateStr, $mode = 'any') {
    $dateEsc = $conn->real_escape_string($dateStr);

    if ($mode === 'any') {
        $where = "m.meta_key='_gk_auto_date' AND m.meta_value='{$dateEsc}'";
    } else {
        $src = $mode === 'auto' ? 'auto' : 'manual';
        $where = "m.meta_key='_gk_auto_date' AND m.meta_value='{$dateEsc}'
                  AND EXISTS (SELECT 1 FROM {$prefix}postmeta s
                              WHERE s.post_id = m.post_id AND s.meta_key='_gk_auto_src'
                                AND s.meta_value='{$src}')";
    }

    $sql = "SELECT COUNT(*) c FROM {$prefix}postmeta m
            JOIN {$prefix}posts p ON p.ID = m.post_id
            WHERE {$where} AND p.post_status='publish'";
    $res = $conn->query($sql);
    return $res ? (int)$res->fetch_assoc()['c'] : 0;
}

/**
 * 为一批文章分配栏目(轮转)
 *
 * 策略:优先「文章总数最少」的栏目,总数相同时优先「最近最久没写」的。
 * 长期运行下来各栏目数量自然趋于均衡,不会全挤在热点/技术里。
 * 同一批次内不会重复分配同一栏目。
 *
 * @return array 形如 [['slug'=>'keji','name'=>'科学会'], ...]
 */
function pick_rotate_categories($conn, $prefix, $rotate, $catNames, $need) {
    if (!$rotate || $need < 1) return [];

    // 每个栏目的文章数 + 最近一次发文日期
    $stats = [];
    foreach ($rotate as $slug) {
        $ttid = resolve_category($conn, $prefix, $slug);
        if (!$ttid) continue;

        // 文章数(只算已发布文章)
        $q = $conn->query("SELECT COUNT(*) c FROM {$prefix}term_relationships r
                           JOIN {$prefix}posts p ON p.ID = r.object_id
                           WHERE r.term_taxonomy_id = {$ttid}
                             AND p.post_status='publish' AND p.post_type='post'");
        $cnt = ($q && ($r = $q->fetch_assoc())) ? (int)$r['c'] : 0;

        // 该栏目最近一篇文章的日期(用来判断"多久没写了")
        $q2 = $conn->query("SELECT MAX(p.post_date) d FROM {$prefix}term_relationships r
                            JOIN {$prefix}posts p ON p.ID = r.object_id
                            WHERE r.term_taxonomy_id = {$ttid}
                              AND p.post_status='publish' AND p.post_type='post'");
        $last = ($q2 && ($r2 = $q2->fetch_assoc()) && $r2['d']) ? $r2['d'] : '2000-01-01 00:00:00';
        $days = (time() - strtotime($last)) / 86400;

        $stats[] = [
            'slug' => $slug,
            'name' => $catNames[$slug] ?? $slug,
            'count' => $cnt,
            'days'  => $days,
            'last'  => $last,
        ];
    }
    if (!$stats) return [];

    // 排序:文章少优先;同等数量时,最久没写的优先;再按 slug 保证稳定
    usort($stats, function ($a, $b) {
        if ($a['count'] !== $b['count']) return $a['count'] <=> $b['count'];
        if ($a['days']  !== $b['days'])  return $b['days']  <=> $a['days'];
        return strcmp($a['slug'], $b['slug']);
    });

    return array_slice($stats, 0, min($need, count($stats)));
}

/** slug → term_taxonomy_id */
function resolve_category($conn, $prefix, $slug) {
    $slug = $conn->real_escape_string($slug);
    $sql = "SELECT tt.term_taxonomy_id FROM {$prefix}terms t
            JOIN {$prefix}term_taxonomy tt ON t.term_id = tt.term_id
            WHERE t.slug='$slug' AND tt.taxonomy='category' LIMIT 1";
    $res = $conn->query($sql);
    if (!$res) { return 0; }
    $row = $res->fetch_assoc();
    return $row ? (int)$row['term_taxonomy_id'] : 0;
}

/**
 * 生成 URL slug
 * 与既有文章保持一致:中文标题走 rawurlencode(WP 默认行为),
 * 且会把标点一并编码(如「,」→ %ef%bc%8c),不能只保留汉字。
 */
function make_post_name($title, $conn) {
    // 与站内既有文章保持同一编码规则(WP 存的是小写十六进制)
    $name = strtolower(rawurlencode(trim($title)));
    // 控制长度,按字符边界截断避免截断转义序列
    if (mb_strlen($name, 'UTF-8') > 180) {
        $name = mb_substr($name, 0, 180, 'UTF-8');
    }
    return $name;
}

/**
 * 写入文章 + 分类关联 + postmeta
 */
function publish_post($conn, $prefix, $site, $data, $dateStr) {
    $title   = trim($data['title']);
    $content = trim($data['content']);
    $excerpt = isset($data['excerpt']) ? trim($data['excerpt']) : '';
    $catSlug = $site['fixed_category'] ?: trim($data['category']);

    if ($title === '' || $content === '') {
        throw new Exception("标题或正文为空");
    }
    // 兜底摘要
    if ($excerpt === '') {
        $excerpt = mb_substr(preg_replace('/\s+/u', ' ', strip_tags($content)), 0, 100);
    }

    $ttid = resolve_category($conn, $prefix, $catSlug);
    if (!$ttid) {
        throw new Exception("分类 slug 无法解析: {$catSlug}");
    }

    $now   = date('Y-m-d H:i:s');
    $nowG  = gmdate('Y-m-d H:i:s');
    $pname = make_post_name($title, $conn);

    $sql = "INSERT INTO {$prefix}posts
            (post_author, post_date, post_date_gmt, post_content, post_title, post_excerpt,
             post_status, comment_status, ping_status, post_password, post_name,
             to_ping, pinged, post_modified, post_modified_gmt, post_content_filtered,
             post_parent, guid, menu_order, post_type, post_mime_type, comment_count)
            VALUES (1, '{$now}', '{$nowG}', '" . $conn->real_escape_string($content) . "',
                    '" . $conn->real_escape_string($title) . "', '" . $conn->real_escape_string($excerpt) . "',
                    'publish', 'open', 'open', '', '" . $conn->real_escape_string($pname) . "',
                    '', '', '{$now}', '{$nowG}', '', 0, '', 0, 'post', '', 0)";
    if (!$conn->query($sql)) {
        throw new Exception("INSERT posts 失败: " . $conn->error);
    }
    $pid = $conn->insert_id;
    if (!$pid) {
        throw new Exception("insert_id 为空");
    }

    // guid
    $guid = $site['siteurl'] . '/?p=' . $pid;
    $conn->query("UPDATE {$prefix}posts SET guid='" . $conn->real_escape_string($guid) . "' WHERE ID={$pid}");

    // 分类关联
    $ok = $conn->query("INSERT INTO {$prefix}term_relationships (object_id, term_taxonomy_id, term_order) VALUES ({$pid}, {$ttid}, 0)");
    if (!$ok) {
        $conn->rollback();
        throw new Exception("关联分类失败: " . $conn->error);
    }
    $conn->query("UPDATE {$prefix}term_taxonomy SET count = count + 1 WHERE term_taxonomy_id={$ttid}");

    // postmeta:去重标记 + 来源标记 + 初始阅读量
    $views = random_views();
    $src = ($GLOBALS['gk_run_mode'] ?? 'auto') === 'manual' ? 'manual' : 'auto';
    $conn->query("INSERT INTO {$prefix}postmeta (post_id, meta_key, meta_value) VALUES
                  ({$pid}, '_gk_auto_date', '" . $conn->real_escape_string($dateStr) . "'),
                  ({$pid}, '_gk_auto_src', '{$src}'),
                  ({$pid}, 'views', '{$views}')");

    return ['post_id' => $pid, 'category' => $catSlug, 'ttid' => $ttid, 'views' => $views];
}

/**
 * 初始阅读量:在 [min, max] 区间内随机取值
 * ⚠️ 说明:wp-postviews 用 postmeta.views 存浏览数,
 *    新文章立刻显示上千次阅读量属于人为构造数据,并非真实访问量。
 *    若追求稳定的搜索排名,建议把 seed_views 设为 false(保持 0 真实起算)。
 */
function random_views() {
    $min = (int)($GLOBALS['cfg']['ai']['seed_views_min'] ?? 1000);
    $max = (int)($GLOBALS['cfg']['ai']['seed_views_max'] ?? 2000);
    if ($max < $min) { $max = $min; }
    return random_int($min, $max);
}

/** 清理站点瞬态缓存,确保新文章立即可见 */
function flush_transients($conn, $prefix) {
    $conn->query("DELETE FROM {$prefix}options WHERE option_name LIKE '\_transient\_%'
                  OR option_name LIKE '\_site\_transient\_%'");
}

/* =========================================================
 * 4. 单站执行
 * =======================================================*/

function run_site($siteKey, $cfg, $force = false, $count = 1, $mode = 'auto') {
    $site = $cfg['sites'][$siteKey];
    $dateStr = date('Y-m-d');
    $GLOBALS['gk_run_mode'] = $mode;

    logline("───── {$site['label']} ─────");

    // key 检查
    $hasKey = false;
    foreach ($cfg['ai']['providers'] as $p) { if (trim($p['key']) !== '') { $hasKey = true; break; } }
    if (!$hasKey) {
        logline("  ⏭ 未配置任何 AI API Key,跳过。编辑 /root/gk_auto/config.php 填写 key。");
        return ['ok' => false, 'reason' => 'no_api_key'];
    }

    $conn = null;
    try {
        $conn = db_connect($site);
        $prefix = $site['prefix'];

        // 去重:按运行模式分开记账
        //   auto   —— 只与 cron 自己发的比,手动发的不会被当成"已完成"
        //   manual —— 只与手动发的比,随时可加,不受自动任务影响
        $alreadyToday = already_published_today($conn, $prefix, $dateStr, $mode);
        $anyToday = already_published_today($conn, $prefix, $dateStr, 'any');

        if ($mode === 'manual') {
            // 手动:默认永远执行,只在显式 --skip-done 时才判断
            if (!$force && !empty($cfg['ai']['manual_skip_done']) && $alreadyToday >= $count) {
                logline("  ⏭ 今日手动已发 {$alreadyToday} 篇(目标 {$count}),跳过");
                return ['ok' => true, 'skipped' => true];
            }
            logline("  手动模式:今日已发 {$anyToday} 篇(其中手动 {$alreadyToday} 篇),本次追加至 {$count} 篇");
        } else {
            $manualN = $anyToday - $alreadyToday;   // 手动发的篇数(PHP 花括号内不支持直接减法)
            if (!$force && $alreadyToday >= $count) {
                $extraNote = $manualN > 0 ? "(另有手动 {$manualN} 篇)" : '';
                logline("  ⏭ 今日自动已发 {$alreadyToday} 篇(目标 {$count}),跳过{$extraNote}");
                return ['ok' => true, 'skipped' => true];
            }
            if ($anyToday > 0) {
                logline("  今日自动已发 {$alreadyToday} 篇,手动 {$manualN} 篇,本次续发至 {$count} 篇");
            }
        }

        // 抓取今日热点(多篇复用同一批热点,失败不阻断)
        $hotBlock = '';
        if (empty($cfg['ai']['no_hotspots'])) {
            logline("  抓取今日热点…");
            $hot = fetch_hotspots($siteKey);
            if ($hot) {
                $hotBlock = render_hotspots($hot);
                $cnt = 0;
                foreach ($hot as $b) { $cnt += count($b['items']); }
                logline("  热点就绪:{$cnt} 条(" . implode(' / ', array_column($hot, 'src')) . ")");
            } else {
                logline("  ⚠ 热点抓取失败,本次不限定时效选题");
            }
        }

        // 栏目轮转:预先给每篇分配一个不同栏目(文章数少的、最久没写的优先)
        // 按本批实际要写的篇数 $count 分配;池子不够时后面的篇退回 AI 自选
        $catPlan = [];
        if (!empty($site['rotate_categories'])) {
            $picked = pick_rotate_categories($conn, $prefix, $site['rotate_categories'],
                                            $site['categories'], $count);
            foreach ($picked as $p) {
                $catPlan[] = ['slug' => $p['slug'], 'name' => $p['name']];
            }
            if ($catPlan) {
                $desc = [];
                foreach ($catPlan as $c) {
                    $desc[] = $c['name'] . "({$c['slug']})";
                }
                logline("  本批栏目分配:" . implode(' / ', $desc));
                // 顺带在日志里体现均衡依据
                foreach ($picked as $p) {
                    logline("    · {$p['name']}:现有 {$p['count']} 篇,上次发文 "
                          . ($p['days'] > 900 ? '很久之前' : round($p['days']) . ' 天前'));
                }
            }
        }

        $results = [];
        $batchTitles = [];   // 本批已发标题,用于交叉去重
        for ($seq = 1; $seq <= $count; $seq++) {
            logline("  ── 第 {$seq}/{$count} 篇 ──");
            $assigned = $catPlan[$seq - 1] ?? null;
            $r = publish_one($conn, $prefix, $site, $siteKey, $dateStr, $hotBlock, $cfg,
                             $seq, $count, $batchTitles, $assigned);
            if (!$r['ok']) {
                logline("  ✗ 第 {$seq} 篇失败:{$r['reason']}");
                $results[] = $r;
                // 单篇失败不中断整批,继续下一篇
                continue;
            }
            $batchTitles[] = $r['title'];
            $results[] = $r;
        }

        $okCnt = 0; $titles = [];
        foreach ($results as $r) {
            if (!empty($r['ok'])) { $okCnt++; $titles[] = $r['title']; }
        }
        if ($okCnt === 0) {
            return ['ok' => false, 'reason' => $results[0]['reason'] ?? 'unknown'];
        }
        return ['ok' => true, 'count' => $okCnt, 'titles' => $titles,
                'posts' => $results, 'title' => implode(' | ', $titles)];

    } catch (Exception $e) {
        logline("  ✗ 异常: " . $e->getMessage());
        return ['ok' => false, 'reason' => 'exception'];
    } finally {
        if ($conn) { @$conn->close(); }
    }
}

/**
 * 生成并发布单篇(由 run_site 循环调用)
 */
function publish_one($conn, $prefix, $site, $siteKey, $dateStr, $hotBlock, $cfg, $seq = 1, $total = 1, $batchTitles = [], $assignedCat = null) {
        // 随机字数区间:下限不低于 2000(用户要求),上限再随机浮动
        $minWords = random_int((int)$site['words'][0], (int)$site['words'][1]);
        $maxWords = $minWords + random_int(300, 900);
        logline("  本篇字数目标:{$minWords}–{$maxWords} 字");

        // 生成(含字数不足自动重写)
        // recent 每次都重新查,天然包含本批已发的文章,避免同批标题重复
        $GLOBALS['gk_batch_titles'] = $batchTitles;   // 供 build_prompt 使用
        $recent = recent_titles($conn, $prefix, 25);
        $siteForPrompt = $site;
        $siteForPrompt['words'] = [$minWords, $maxWords];
        $p = build_prompt($siteKey, $siteForPrompt, $dateStr, $recent, $hotBlock, $seq, $total, $assignedCat);
        $minWords = $siteForPrompt['words'][0];

        $data = null;
        for ($round = 1; $round <= 2; $round++) {
            logline("  调用 AI 生成内容(第 $round 轮)…");
            $raw = ai_chat($p['task'], $p['system'], $cfg);

            if (strpos($raw, 'AI_CALL_FAILED::') === 0) {
                logline("  ✗ " . substr($raw, 16));
                return ['ok' => false, 'reason' => 'ai_failed'];
            }

            $parsed = extract_json($raw);
            if (!$parsed || empty($parsed['title']) || empty($parsed['content'])) {
                logline("  ✗ AI 返回内容无法解析为有效 JSON");
                logline("     片段: " . mb_substr($raw, 0, 300));
                return ['ok' => false, 'reason' => 'bad_json'];
            }

            $parsed['content'] = strip_malicious($parsed['content']);

            // 医疗医药选题兜底:提示词只能引导,这里做硬拦截。
            // 只看标题和摘要——正文偶尔提到"医院"(如写历史、写技术史)是正常的,
            // 但标题/摘要命中就说明整篇主题落在医疗医药上。
            $medWord = med_hit($parsed['title'] . ' ' . ($parsed['excerpt'] ?? ''));
            if ($medWord !== '') {
                if ($round < 2) {
                    logline("  ⚠ 选题涉及医疗(命中「{$medWord}」),要求换主题重写…");
                    $p['task'] = "你上一篇选题《{$parsed['title']}》涉及医疗/医药/健康内容,违反了硬性要求。\n"
                               . "请**完全换一个主题**重新写作,且不得与医疗、医药、疾病、诊疗、药品、健康养生有任何关系。\n"
                               . "其余写作要求(字数、结构、HTML 标签、输出 JSON 格式)保持原样。";
                    continue;
                }
                logline("  ✗ 两次生成都落在医疗选题(命中「{$medWord}」),跳过本篇");
                return ['ok' => false, 'reason' => 'medical_topic'];
            }

            $actual = count_chinese($parsed['content']);
            if ($actual >= $minWords || $round == 2) {
                if ($actual < $minWords) {
                    logline("  ⚠ 字数 {$actual} 低于下限 {$minWords},重写次数已用尽,继续发布");
                } else {
                    logline("  字数校验通过:{$actual} 字(下限 {$minWords})");
                }
                $data = $parsed;
                break;
            }

            // 字数不足 → 让 AI 在原文基础上扩写,而不是整篇重来
            logline("  ⚠ 字数 {$actual} 低于下限 {$minWords},要求 AI 扩写…");
            $expand = "你上一篇文章《{$parsed['title']}》只有 {$actual} 个中文字符,未达到 {$minWords} 字的要求。\n"
                    . "请在**保留原有观点、结构和文风**的前提下扩写,要求:\n"
                    . "1. 为每个已有小节补充具体案例、数据、细节或技术解释,不要空泛注水;\n"
                    . "2. 可以新增 1–2 个小节展开原本一笔带过但值得深挖的观点;\n"
                    . "3. 不要编造事实与引用;确无把握的数字请略去。\n"
                    . "4. 仍只输出 JSON:{\"title\":\"原标题\",\"category\":\"{$parsed['category']}\",\"excerpt\":\"摘要\",\"content\":\"完整改写后的全文(HTML,含全文,不要只给新增部分)\"}";
            $p['task'] = $expand;
        }

        // 配图:按主题抓图 → 存媒体库 → 插到正文最前面
        $heroInfo = null;
        if (!empty($cfg['ai']['enable_hero_image'])) {
            logline("  抓取主题配图…");
            $t0 = microtime(true);
            $img = gk_fetch_topic_image($data['title'], $data['content'], $cfg, function ($m) {
                logline($m);
            });
            if ($img) {
                $media = gk_save_media($conn, $prefix,
                    $cfg['sites'][$siteKey]['dir'] ?? '', $site['siteurl'], $img, $data['title']);
                if ($media) {
                    $html = gk_image_html($media['url'], $data['title']);
                    $data['content'] = $html . $data['content'];
                    $heroInfo = ['url' => $media['url'], 'attach_id' => $media['attach_id']];
                    logline("  ✓ 配图已插入:{$img['w']}x{$img['h']} "
                          . round(strlen($img['data'])/1024) . "KB 得分{$img['score']} "
                          . "(" . round(microtime(true) - $t0, 1) . "s)");
                    logline("    {$media['url']}");
                } else {
                    logline("  ⚠ 图片已取到但写入媒体库失败,本篇不配图");
                }
            } else {
                logline("  ⚠ 未找到合适配图,本篇无图发布(不影响正文)");
            }
        }

        // 分类判定:优先级 固定分类 > 轮转分配的栏目 > AI 返回的分类
        if (!empty($site['fixed_category'])) {
            $cat = $site['fixed_category'];
        } elseif (is_array($assignedCat) && !empty($assignedCat['slug'])) {
            // 轮转指定的栏目优先,AI 的选择仅作日志参考
            $cat = $assignedCat['slug'];
            $aiCat = trim($data['category'] ?? '');
            if ($aiCat !== '' && $aiCat !== $cat) {
                logline("  · AI 原选分类 '{$aiCat}',按轮转计划改用 '{$cat}'");
            }
        } else {
            $cat = trim($data['category'] ?? '');
        }

        if (!isset($site['categories'][$cat])) {
            logline("  ⚠ 分类 '{$cat}' 不在白名单,改用首个分类 " . array_key_first($site['categories']));
            $cat = array_key_first($site['categories']);
        }
        $data['category'] = $cat;

        // 标题相似度检测:拦截同批内撞题与近似标题
        $norm = function ($s) {
            // 只保留汉字与字母数字,去掉标点,标准化比对
            return mb_strtolower(preg_replace('/[^\p{Han}a-z0-9]/u', '', (string)$s));
        };
        /** 粗粒度相似度:取标题中的 3 字滑窗,计算重合比例 */
        $sim = function ($a, $b) {
            $a = preg_replace('/[^\p{Han}]/u', '', $a);
            $b = preg_replace('/[^\p{Han}]/u', '', $b);
            if (mb_strlen($a, 'UTF-8') < 4 || mb_strlen($b, 'UTF-8') < 4) return 0.0;
            $grams = [];
            for ($i = 0; $i <= mb_strlen($a, 'UTF-8') - 3; $i++) {
                $grams[mb_substr($a, $i, 3, 'UTF-8')] = true;
            }
            $hit = 0; $total = 0;
            for ($i = 0; $i <= mb_strlen($b, 'UTF-8') - 3; $i++) {
                $total++;
                if (isset($grams[mb_substr($b, $i, 3, 'UTF-8')])) $hit++;
            }
            return $total > 0 ? $hit / $total : 0.0;
        };

        // 与库中既有文章比对
        $existing = recent_titles($conn, $prefix, 30);
        foreach (array_merge($existing, $batchTitles) as $t) {
            $s = $sim($norm($data['title']), $norm($t));
            if ($s >= 0.5) {
                logline("  ✗ 标题与《{$t}》相似度 " . round($s * 100) . "%,判定撞题,放弃本篇");
                logline("     本次标题:{$data['title']}");
                return ['ok' => false, 'reason' => 'duplicate_title'];
            }
        }

        // 发布
        $res = publish_post($conn, $prefix, $site, $data, $dateStr);
        flush_transients($conn, $prefix);

        $url = get_permalink_by_id($conn, $prefix, $res['post_id'], $site['siteurl']);
        logline("  ✓ 发布成功 #{$res['post_id']} 《{$data['title']}》");
        logline("    分类: {$cat} / {$site['categories'][$cat]}");
        logline("    字数: " . count_chinese($data['content']) . ",初始阅读量: {$res['views']}");
        logline("    链接: {$url}");

        // 发布后立即推送到百度(每篇都推)
        // 注意:site 参数是「百度平台验证的站点」,可能与本机对外域名不同。
        // 例如主站对外是 www.geekfei.cn,但验证的是 geekfei.cn,
        // 此时 site=geekfei.cn 配 www 的 URL 会返回 not_same_site,
        // 必须把 URL 的 host 换成 baidu_push_host 才能匹配。
        $pushMsg = '未推送';
        if (!empty($site['baidu_site'])) {
            $pushUrl = $url;
            $pushHost = trim((string)($site['baidu_push_host'] ?? ''));
            if ($pushHost !== '') {
                $pushUrl = preg_replace('#^https?://[^/]+#i', 'https://' . $pushHost, $url);
            }
            list($pushed, $m) = baidu_push_url($site['baidu_site'], $cfg['ai']['baidu_token'], $pushUrl);
            $pushMsg = $m;
            logline($pushed ? "    百度推送: ✓ $m" : "    百度推送: ✗ $m");
            if ($pushUrl !== $url) {
                logline("      (推送地址: {$pushUrl})");
            }
        }

        return ['ok' => true, 'post_id' => $res['post_id'], 'title' => $data['title'],
                'category' => $cat, 'url' => $url, 'views' => $res['views'],
                'words' => count_chinese($data['content']), 'push' => $pushMsg];
}

/** 剔除危险标签与 markdown 残留 */
function strip_malicious($html) {
    $html = preg_replace('#<(script|iframe|object|embed|form|style)[^>]*>.*?</\1>#is', '', $html);
    $html = preg_replace('#<(script|iframe|object|embed|form|style)[^>]*/?>#is', '', $html);
    // markdown 残留(模型偶尔不听话)
    $html = preg_replace('/^#{1,6}\s*/mu', '', $html);
    $html = str_replace(['**', '```'], '', $html);
    return trim($html);
}

/** 统计正文有效字数:剥标签后计中文字符 + 英文单词 */
function count_chinese($html) {
    $text = strip_tags($html);
    $text = html_entity_decode($text, ENT_QUOTES, 'UTF-8');
    $han  = preg_match_all('/[\x{4e00}-\x{9fa5}]/u', $text, $m);   // 汉字
    $word = preg_match_all('/[A-Za-z]+/u', $text, $m2);               // 英文单词按词计
    return $han + $word;
}

/**
 * 生成可点击的永久链接
 * 三站 permalink 结构不同,不硬编码,改用「站点根 URL + slug」并回退 ?p=
 */
function get_permalink_by_id($conn, $prefix, $pid, $siteurl) {
    $sql = "SELECT post_name FROM {$prefix}posts WHERE ID={$pid}";
    $res = $conn->query($sql);
    $slug = ($res && ($r = $res->fetch_assoc())) ? $r['post_name'] : '';
    if ($slug !== '' && $slug !== null) {
        return rtrim($siteurl, '/') . '/' . $slug . '/';
    }
    return $siteurl . '/?p=' . $pid;
}

/* =========================================================
 * 5. 入口
 * =======================================================*/

$argv = $_SERVER['argv'];
$target = $argv[1] ?? 'all';
$force  = in_array('--force', $argv, true);
// 运行模式:默认 auto(cron),--manual 为手动追加,两者分开记账互不干涉
$mode   = in_array('--manual', $argv, true) ? 'manual' : 'auto';

if ($target === '--manual') { $target = 'all'; }

if ($target === '--test-ai') {
    logline("=== AI 连通性测试 ===");
    foreach ($cfg['ai']['providers'] as $p) {
        $has = trim($p['key']) !== '';
        logline("  {$p['name']} ({$p['model']}): " . ($has ? '已配置 key' : '未配置 key'));
    }
    $r = ai_chat('用一句话介绍你自己,不超过20字。', '你是助手。', $cfg);
    if (strpos($r, 'AI_CALL_FAILED::') === 0) {
        logline("✗ " . substr($r, 16));
        exit(1);
    }
    logline("✓ 响应: " . mb_substr($r, 0, 200));
    exit(0);
}

$targets = ($target === 'all') ? array_keys($cfg['sites']) : [$target];

foreach ($targets as $t) {
    if (!isset($cfg['sites'][$t])) {
        logline("未知站点: {$t}(可选: " . implode(', ', array_keys($cfg['sites'])) . ", all, --test-ai)");
        exit(1);
    }
}

// --count N:每站发几篇(未指定时按模式取默认值)
$count = null;
foreach ($argv as $i => $a) {
    if ($a === '--count' && isset($argv[$i + 1])) {
        $count = max(1, min(20, (int)$argv[$i + 1]));
    } elseif (preg_match('/^--count=(\d+)$/', $a, $m)) {
        $count = max(1, min(20, (int)$m[1]));
    }
}

// 手动模式:未指定篇数时交互询问
$interactive = false;
if ($count === null && $mode === 'manual' && posix_isatty(STDIN)) {
    $interactive = true;
    echo "\n════════ 手动发文 ════════\n";
    echo "请选择每站发布篇数:\n";
    echo "  1) 每站 1 篇(共 3 篇)\n";
    echo "  2) 每站 2 篇(共 6 篇)\n";
    echo "  3) 每站 3 篇(共 9 篇)\n";
    echo "  4) 自定义\n";
    echo "请输入 [1-4](直接回车 = 1): ";
    $ans = trim((string)fgets(STDIN));
    if ($ans === '') { $ans = '1'; }
    $map = ['1' => 1, '2' => 2, '3' => 3];
    if (isset($map[$ans])) {
        $count = $map[$ans];
    } elseif ($ans === '4') {
        echo "请输入每站篇数(1-20): ";
        $n = (int)trim((string)fgets(STDIN));
        $count = ($n >= 1 && $n <= 20) ? $n : 1;
    } else {
        echo "输入无效,按每站 1 篇执行\n";
        $count = 1;
    }
    echo "即将执行:每站 {$count} 篇\n\n";
}
if ($count === null) {
    $count = ($mode === 'manual')
        ? (int)($cfg['ai']['manual_count_default'] ?? 1)
        : (int)($cfg['ai']['auto_count'] ?? 1);
}

logline("===== gk_auto 启动 " . date('Y-m-d H:i:s')
      . "(模式: " . ($mode === 'manual' ? '手动' : '自动')
      . ",目标: " . implode(',', $targets)
      . ($force ? ", FORCE" : "")
      . ", 每站 {$count} 篇"
      . ($interactive ? ", 交互选择" : "") . ")=====");

$summary = [];
foreach ($targets as $t) {
    $r = run_site($t, $cfg, $force, $count, $mode);
    $summary[$t] = $r;
    logline("");
}

logline("===== 汇总 =====");
$okSites = 0; $okPosts = 0;
foreach ($summary as $k => $v) {
    $label = $cfg['sites'][$k]['label'];
    if (!empty($v['skipped'])) {
        logline("  ⏭ $label 跳过");
    } elseif (!empty($v['ok'])) {
        $okSites++;
        $okPosts += $v['count'];
        foreach ($v['posts'] as $i => $p) {
            if (!empty($p['ok'])) {
                logline("  ✓ $label [" . ($i + 1) . "] 《{$p['title']}》 {$p['words']}字 / 阅读{$p['views']} / {$p['category']}");
            }
        }
    } else {
        logline("  ✗ $label 失败: {$v['reason']}");
    }
}
logline("共成功 {$okPosts} 篇({$okSites}/" . count($summary) . " 站),耗时 "
      . round(microtime(true) - $SCRIPT_START, 1) . "s");

exit($okPosts > 0 ? 0 : 1);