<?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);