SeoAi.php 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. <?php
  2. namespace app\common\util;
  3. class SeoAi
  4. {
  5. public static function generateByMidObj($mid, $objId)
  6. {
  7. $mid = intval($mid);
  8. $objId = intval($objId);
  9. if ($mid === 1) {
  10. $res = model('Vod')->infoData(['vod_id' => ['eq', $objId]], '*', 0);
  11. if ($res['code'] !== 1 || empty($res['info'])) {
  12. return ['code' => 0, 'msg' => 'vod not found'];
  13. }
  14. return self::generateForVod($res['info']);
  15. }
  16. if ($mid === 2) {
  17. $res = model('Art')->infoData(['art_id' => ['eq', $objId]], '*', 0);
  18. if ($res['code'] !== 1 || empty($res['info'])) {
  19. return ['code' => 0, 'msg' => 'art not found'];
  20. }
  21. return self::generateForArt($res['info']);
  22. }
  23. return ['code' => 0, 'msg' => 'unsupported mid'];
  24. }
  25. public static function generateForVod($vod)
  26. {
  27. $payload = [
  28. 'mid' => 1,
  29. 'obj_id' => intval($vod['vod_id']),
  30. 'name' => (string)$vod['vod_name'],
  31. 'subtitle' => (string)$vod['vod_sub'],
  32. 'blurb' => (string)$vod['vod_blurb'],
  33. 'content' => strip_tags((string)$vod['vod_content']),
  34. 'class' => (string)$vod['vod_class'],
  35. 'tag' => (string)$vod['vod_tag'],
  36. 'year' => (string)$vod['vod_year'],
  37. 'area' => (string)$vod['vod_area'],
  38. 'lang' => (string)$vod['vod_lang'],
  39. ];
  40. return self::generateAndSave($payload);
  41. }
  42. public static function generateForArt($art)
  43. {
  44. $payload = [
  45. 'mid' => 2,
  46. 'obj_id' => intval($art['art_id']),
  47. 'name' => (string)$art['art_name'],
  48. 'subtitle' => (string)$art['art_sub'],
  49. 'blurb' => (string)$art['art_blurb'],
  50. 'content' => strip_tags(str_replace('$$$', '', (string)$art['art_content'])),
  51. 'class' => (string)$art['art_class'],
  52. 'tag' => (string)$art['art_tag'],
  53. 'year' => date('Y', intval($art['art_time'])),
  54. 'area' => '',
  55. 'lang' => '',
  56. ];
  57. return self::generateAndSave($payload);
  58. }
  59. private static function generateAndSave($payload)
  60. {
  61. // Ensure SEO output follows current system language setting.
  62. $payload['target_lang'] = self::resolveTargetLanguage($payload);
  63. $sourceHash = sha1(json_encode($payload));
  64. $result = self::runGenerator($payload);
  65. $safeTitle = mac_filter_xss((string)$result['title']);
  66. $safeKeywords = mac_filter_xss((string)$result['keywords']);
  67. $safeDescription = mac_filter_xss((string)$result['description']);
  68. $saveData = [
  69. 'title' => $safeTitle,
  70. 'keywords' => $safeKeywords,
  71. 'description' => $safeDescription,
  72. 'provider' => $result['provider'],
  73. 'model' => $result['model'],
  74. 'source_hash' => $sourceHash,
  75. 'error' => $result['error'],
  76. 'status' => $result['status'],
  77. ];
  78. model('SeoAiResult')->saveByObject($payload['mid'], $payload['obj_id'], $saveData);
  79. return ['code' => $result['status'] ? 1 : 0, 'msg' => $result['error'], 'data' => $saveData];
  80. }
  81. private static function runGenerator($payload)
  82. {
  83. $config = config('maccms');
  84. $ai = isset($config['ai_seo']) ? $config['ai_seo'] : [];
  85. $enabled = isset($ai['enabled']) ? intval($ai['enabled']) : 0;
  86. $provider = !empty($ai['provider']) ? strtolower($ai['provider']) : 'fallback';
  87. $model = !empty($ai['model']) ? $ai['model'] : 'gpt-4o-mini';
  88. if ($enabled !== 1 || empty($ai['api_key']) || $provider !== 'openai') {
  89. return self::fallbackResult($payload, $provider, $model, '');
  90. }
  91. $apiBase = !empty($ai['api_base']) ? rtrim($ai['api_base'], '/') : 'https://api.openai.com/v1';
  92. $url = $apiBase . '/chat/completions';
  93. $prompt = self::buildPrompt($payload);
  94. $post = [
  95. 'model' => $model,
  96. 'temperature' => 0.4,
  97. 'response_format' => ['type' => 'json_object'],
  98. 'messages' => [
  99. ['role' => 'system', 'content' => 'You are an SEO assistant. Return strict JSON with keys: title,keywords,description.'],
  100. ['role' => 'user', 'content' => $prompt],
  101. ],
  102. ];
  103. $headers = [
  104. 'Content-Type: application/json',
  105. 'Authorization: Bearer ' . trim($ai['api_key']),
  106. ];
  107. $respBody = mac_curl_post($url, json_encode($post, JSON_UNESCAPED_UNICODE), $headers);
  108. if ($respBody === false || $respBody === '') {
  109. return self::fallbackResult($payload, $provider, $model, 'empty ai response');
  110. }
  111. $json = json_decode((string)$respBody, true);
  112. $content = (string)$json['choices'][0]['message']['content'];
  113. $parsed = json_decode($content, true);
  114. if (empty($parsed) || empty($parsed['title'])) {
  115. return self::fallbackResult($payload, $provider, $model, 'invalid ai response');
  116. }
  117. return [
  118. 'status' => 1,
  119. 'provider' => $provider,
  120. 'model' => $model,
  121. 'title' => self::normalizeTitle($parsed['title']),
  122. 'keywords' => self::normalizeKeywords($parsed['keywords']),
  123. 'description' => self::normalizeDescription($parsed['description']),
  124. 'error' => '',
  125. ];
  126. }
  127. private static function buildPrompt($payload)
  128. {
  129. $type = $payload['mid'] == 1 ? 'video detail page' : 'article detail page';
  130. $targetLang = !empty($payload['target_lang']) ? $payload['target_lang'] : 'English';
  131. return "Generate SEO metadata for a {$type}.\n" .
  132. "Language: {$targetLang}.\n" .
  133. "Name: {$payload['name']}\n" .
  134. "Subtitle: {$payload['subtitle']}\n" .
  135. "Category: {$payload['class']}\n" .
  136. "Tags: {$payload['tag']}\n" .
  137. "Year: {$payload['year']}\n" .
  138. "Area: {$payload['area']}\n" .
  139. "Lang: {$payload['lang']}\n" .
  140. "Blurb: " . self::cut($payload['blurb'], 220) . "\n" .
  141. "Content excerpt: " . self::cut($payload['content'], 350) . "\n" .
  142. "Rules:\n" .
  143. "1) title 50-65 chars.\n" .
  144. "2) description 120-160 chars.\n" .
  145. "3) keywords 6-12 items, comma separated.\n" .
  146. "4) no fake facts.\n" .
  147. "Return JSON only.";
  148. }
  149. private static function resolveTargetLanguage($payload)
  150. {
  151. $sysLang = strtolower((string)config('maccms.app.lang'));
  152. if ($sysLang === '') {
  153. $sysLang = strtolower((string)config('default_lang'));
  154. }
  155. if ($sysLang === '' && !empty($payload['lang'])) {
  156. $sysLang = strtolower((string)$payload['lang']);
  157. }
  158. // Keep prompt language explicit for stable multilingual output.
  159. $langMap = [
  160. 'zh-cn' => 'Chinese (Simplified)',
  161. 'zh-hans' => 'Chinese (Simplified)',
  162. 'zh-tw' => 'Chinese (Traditional)',
  163. 'zh-hk' => 'Chinese (Traditional)',
  164. 'zh-hant' => 'Chinese (Traditional)',
  165. 'en-us' => 'English',
  166. 'en-gb' => 'English',
  167. 'en' => 'English',
  168. 'ja-jp' => 'Japanese',
  169. 'ja' => 'Japanese',
  170. 'ko-kr' => 'Korean',
  171. 'ko' => 'Korean',
  172. 'fr-fr' => 'French',
  173. 'fr' => 'French',
  174. 'de-de' => 'German',
  175. 'de' => 'German',
  176. 'es-es' => 'Spanish',
  177. 'es' => 'Spanish',
  178. 'pt-pt' => 'Portuguese',
  179. 'pt-br' => 'Portuguese',
  180. 'pt' => 'Portuguese',
  181. ];
  182. if (isset($langMap[$sysLang])) {
  183. return $langMap[$sysLang];
  184. }
  185. if (strpos($sysLang, 'zh') === 0) {
  186. return 'Chinese (Simplified)';
  187. }
  188. if (strpos($sysLang, 'en') === 0) {
  189. return 'English';
  190. }
  191. return 'English';
  192. }
  193. private static function fallbackResult($payload, $provider, $model, $error)
  194. {
  195. $siteName = (string)config('maccms.site.site_name');
  196. $title = self::normalizeTitle($payload['name'] . ($siteName ? ' - ' . $siteName : ''));
  197. $keywords = self::normalizeKeywords(
  198. implode(',', array_filter([
  199. $payload['name'], $payload['subtitle'], $payload['class'], $payload['tag'], $payload['year'], $payload['area'], $payload['lang']
  200. ]))
  201. );
  202. $description = self::normalizeDescription($payload['blurb']);
  203. if (empty($description)) {
  204. $description = self::normalizeDescription($payload['content']);
  205. }
  206. return [
  207. // 2 marks fallback SEO content, distinct from AI-success status 1.
  208. 'status' => 2,
  209. 'provider' => $provider ?: 'fallback',
  210. 'model' => $model ?: 'fallback',
  211. 'title' => $title,
  212. 'keywords' => $keywords,
  213. 'description' => $description,
  214. 'error' => $error,
  215. ];
  216. }
  217. private static function normalizeTitle($text)
  218. {
  219. $text = trim(strip_tags((string)$text));
  220. return self::cut($text, 255);
  221. }
  222. private static function normalizeKeywords($text)
  223. {
  224. $text = trim(strip_tags((string)$text));
  225. $text = str_replace(['|', ',', '、', ';'], ',', $text);
  226. $arr = array_filter(array_map('trim', explode(',', $text)));
  227. $arr = array_unique($arr);
  228. $arr = array_slice($arr, 0, 12);
  229. return self::cut(implode(',', $arr), 500);
  230. }
  231. private static function normalizeDescription($text)
  232. {
  233. $text = trim(preg_replace('/\s+/', ' ', strip_tags((string)$text)));
  234. return self::cut($text, 500);
  235. }
  236. private static function cut($text, $len)
  237. {
  238. $text = (string)$text;
  239. if (mb_strlen($text, 'UTF-8') <= $len) {
  240. return $text;
  241. }
  242. return mb_substr($text, 0, $len, 'UTF-8');
  243. }
  244. }