fix: resolve 10 code review issues

High priority:
- Fix concurrent race condition for view_count/like_count (atomic update)
- Add route request ID tracking to prevent race conditions
- Filter get_joke by status=approved (no pending content leak)
- Add error feedback for like button

Performance:
- Optimize random joke query (avoid full table sort)
- Limit page_size max to 100 (DoS prevention)

Medium:
- Add localStorage quota error handling
- Handle empty AI response gracefully
- Fix generate content title extraction

Low:
- Add rejected_jokes to stats API
- Update dashboard to show rejected count
This commit is contained in:
bwstudio
2026-06-02 20:35:08 +08:00
parent 0b43973236
commit ceed63fcb0
144 changed files with 191660 additions and 270 deletions
+9 -1
View File
@@ -6,7 +6,15 @@
"Skill(subagent-driven-development)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(python -c \"from app.models import Joke, JokeType, JokeCrowd, AdminUser; print\\('All models imported successfully'\\)\")"
"Bash(python -c \"from app.models import Joke, JokeType, JokeCrowd, AdminUser; print\\('All models imported successfully'\\)\")",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:8001/health)",
"Bash(curl -s http://localhost:8001/api/auth/login -H \"Content-Type: application/json\" -d \"{\\\\\"username\\\\\":\\\\\"admin\\\\\",\\\\\"password\\\\\":\\\\\"admin123\\\\\"}\")",
"Bash(python -c \"import sys,json;d=json.load\\(sys.stdin\\);print\\(f'Jokes: {d[\\\\\"total\\\\\"]} total'\\)\")",
"Bash(npm run *)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:3000)",
"Bash(python -m uvicorn main:app --reload --port 8001)",
"Bash(python -c \"from app.schemas.joke import GenerateRequest, GenerateResponse; print\\('OK'\\)\")",
"Bash(python -c \"from main import app; print\\('API imports OK'\\)\")"
]
}
}
+1
View File
@@ -0,0 +1 @@
{"title":"程序员的幽默","content":"程序员去相亲,女方问:你有什么优点?程序员答:我bug少。女方:...","type_id":2,"crowd_id":2,"id":1,"status":"approved","view_count":2,"like_count":0,"created_at":"2026-05-21T14:05:38","updated_at":"2026-05-22T10:40:03","type_name":"谐音梗","crowd_name":"职场"}
+1
View File
@@ -0,0 +1 @@
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhZG1pbiIsImV4cCI6MTc4MDM1NDE1OX0.jqZHtndPbRhPYY7ZrkRQGqLzAMtfbejzkhRCq7fwqGY","token_type":"bearer"}
+49
View File
@@ -0,0 +1,49 @@
{
"hash": "529e4e6a",
"configHash": "47bdc112",
"lockfileHash": "81bd2f5f",
"browserHash": "e99f2664",
"optimized": {
"axios": {
"src": "../../axios/index.js",
"file": "axios.js",
"fileHash": "64a84452",
"needsInterop": false
},
"element-plus": {
"src": "../../element-plus/es/index.mjs",
"file": "element-plus.js",
"fileHash": "d8579f02",
"needsInterop": false
},
"pinia": {
"src": "../../pinia/dist/pinia.mjs",
"file": "pinia.js",
"fileHash": "cc8debcd",
"needsInterop": false
},
"vue": {
"src": "../../vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "7c6ca729",
"needsInterop": false
},
"vue-router": {
"src": "../../vue-router/dist/vue-router.mjs",
"file": "vue-router.js",
"fileHash": "bb6a478b",
"needsInterop": false
}
},
"chunks": {
"chunk-YFT6OQ5R": {
"file": "chunk-YFT6OQ5R.js"
},
"chunk-UC4PQXLF": {
"file": "chunk-UC4PQXLF.js"
},
"chunk-G3PMV62Z": {
"file": "chunk-G3PMV62Z.js"
}
}
}
+3108
View File
File diff suppressed because it is too large Load Diff
+7
View File
File diff suppressed because one or more lines are too long
+36
View File
@@ -0,0 +1,36 @@
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
export {
__commonJS,
__export,
__toESM
};
//# sourceMappingURL=chunk-G3PMV62Z.js.map
+7
View File
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}
+13045
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+162
View File
@@ -0,0 +1,162 @@
// node_modules/@vue/devtools-api/lib/esm/env.js
function getDevtoolsGlobalHook() {
return getTarget().__VUE_DEVTOOLS_GLOBAL_HOOK__;
}
function getTarget() {
return typeof navigator !== "undefined" && typeof window !== "undefined" ? window : typeof globalThis !== "undefined" ? globalThis : {};
}
var isProxyAvailable = typeof Proxy === "function";
// node_modules/@vue/devtools-api/lib/esm/const.js
var HOOK_SETUP = "devtools-plugin:setup";
var HOOK_PLUGIN_SETTINGS_SET = "plugin:settings:set";
// node_modules/@vue/devtools-api/lib/esm/time.js
var supported;
var perf;
function isPerformanceSupported() {
var _a;
if (supported !== void 0) {
return supported;
}
if (typeof window !== "undefined" && window.performance) {
supported = true;
perf = window.performance;
} else if (typeof globalThis !== "undefined" && ((_a = globalThis.perf_hooks) === null || _a === void 0 ? void 0 : _a.performance)) {
supported = true;
perf = globalThis.perf_hooks.performance;
} else {
supported = false;
}
return supported;
}
function now() {
return isPerformanceSupported() ? perf.now() : Date.now();
}
// node_modules/@vue/devtools-api/lib/esm/proxy.js
var ApiProxy = class {
constructor(plugin, hook) {
this.target = null;
this.targetQueue = [];
this.onQueue = [];
this.plugin = plugin;
this.hook = hook;
const defaultSettings = {};
if (plugin.settings) {
for (const id in plugin.settings) {
const item = plugin.settings[id];
defaultSettings[id] = item.defaultValue;
}
}
const localSettingsSaveId = `__vue-devtools-plugin-settings__${plugin.id}`;
let currentSettings = Object.assign({}, defaultSettings);
try {
const raw = localStorage.getItem(localSettingsSaveId);
const data = JSON.parse(raw);
Object.assign(currentSettings, data);
} catch (e) {
}
this.fallbacks = {
getSettings() {
return currentSettings;
},
setSettings(value) {
try {
localStorage.setItem(localSettingsSaveId, JSON.stringify(value));
} catch (e) {
}
currentSettings = value;
},
now() {
return now();
}
};
if (hook) {
hook.on(HOOK_PLUGIN_SETTINGS_SET, (pluginId, value) => {
if (pluginId === this.plugin.id) {
this.fallbacks.setSettings(value);
}
});
}
this.proxiedOn = new Proxy({}, {
get: (_target, prop) => {
if (this.target) {
return this.target.on[prop];
} else {
return (...args) => {
this.onQueue.push({
method: prop,
args
});
};
}
}
});
this.proxiedTarget = new Proxy({}, {
get: (_target, prop) => {
if (this.target) {
return this.target[prop];
} else if (prop === "on") {
return this.proxiedOn;
} else if (Object.keys(this.fallbacks).includes(prop)) {
return (...args) => {
this.targetQueue.push({
method: prop,
args,
resolve: () => {
}
});
return this.fallbacks[prop](...args);
};
} else {
return (...args) => {
return new Promise((resolve) => {
this.targetQueue.push({
method: prop,
args,
resolve
});
});
};
}
}
});
}
async setRealTarget(target) {
this.target = target;
for (const item of this.onQueue) {
this.target.on[item.method](...item.args);
}
for (const item of this.targetQueue) {
item.resolve(await this.target[item.method](...item.args));
}
}
};
// node_modules/@vue/devtools-api/lib/esm/index.js
function setupDevtoolsPlugin(pluginDescriptor, setupFn) {
const descriptor = pluginDescriptor;
const target = getTarget();
const hook = getDevtoolsGlobalHook();
const enableProxy = isProxyAvailable && descriptor.enableEarlyProxy;
if (hook && (target.__VUE_DEVTOOLS_PLUGIN_API_AVAILABLE__ || !enableProxy)) {
hook.emit(HOOK_SETUP, pluginDescriptor, setupFn);
} else {
const proxy = enableProxy ? new ApiProxy(descriptor, hook) : null;
const list = target.__VUE_DEVTOOLS_PLUGINS__ = target.__VUE_DEVTOOLS_PLUGINS__ || [];
list.push({
pluginDescriptor: descriptor,
setupFn,
proxy
});
if (proxy) {
setupFn(proxy.proxiedTarget);
}
}
}
export {
setupDevtoolsPlugin
};
//# sourceMappingURL=chunk-YFT6OQ5R.js.map
File diff suppressed because one or more lines are too long
+71932
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+1561
View File
File diff suppressed because it is too large Load Diff
+7
View File
File diff suppressed because one or more lines are too long
+2247
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+348
View File
@@ -0,0 +1,348 @@
import {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBaseVNode,
createBlock,
createCommentVNode,
createElementBlock,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
nodeOps,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
patchProp,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
} from "./chunk-UC4PQXLF.js";
import "./chunk-G3PMV62Z.js";
export {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBlock,
createCommentVNode,
createElementBlock,
createBaseVNode as createElementVNode,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
nodeOps,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
patchProp,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
};
//# sourceMappingURL=vue.js.map
+7
View File
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}
+2 -2
View File
@@ -1,7 +1,7 @@
import request from './request'
export const getTypes = () => request.get('/types')
export const getCrowds = () => request.get('/crowds')
export const getTypes = () => request.get('/categories/types')
export const getCrowds = () => request.get('/categories/crowds')
export const createType = data => request.post('/admin/types', data)
export const updateType = (id, data) => request.put(`/admin/types/${id}`, data)
export const deleteType = id => request.delete(`/admin/types/${id}`)
+4
View File
@@ -0,0 +1,4 @@
import request from './request'
export const getFeedbacks = () => request.get('/admin/feedbacks')
export const deleteFeedback = id => request.delete('/admin/feedbacks/' + id)
+7
View File
@@ -0,0 +1,7 @@
import request from './request'
export const getLinks = () => request.get('/admin/links')
export const getLink = id => request.get('/admin/links/' + id)
export const createLink = data => request.post('/admin/links', data)
export const updateLink = (id, data) => request.put('/admin/links/' + id, data)
export const deleteLink = id => request.delete('/admin/links/' + id)
+4 -4
View File
@@ -13,15 +13,15 @@ request.interceptors.request.use(config => {
return config
})
request.interceptors.response.use({
successHandler: res => res.data,
errorHandler: err => {
request.interceptors.response.use(
res => res.data,
err => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(err)
}
})
)
export default request
+8
View File
@@ -0,0 +1,8 @@
import request from './request'
export const getSettings = () => request.get('/admin/settings')
export const getActiveSetting = () => request.get('/admin/settings/active')
export const createSetting = data => request.post('/admin/settings', data)
export const updateSetting = (id, data) => request.put(`/admin/settings/${id}`, data)
export const toggleSetting = id => request.put(`/admin/settings/${id}/toggle`)
export const deleteSetting = id => request.delete(`/admin/settings/${id}`)
+4
View File
@@ -16,6 +16,10 @@ const routes = [
{ path: 'jokes/edit/:id?', name: 'JokeEdit', component: () => import('@/views/joke/Edit.vue') },
{ path: 'category/type', name: 'CategoryType', component: () => import('@/views/category/Type.vue') },
{ path: 'category/crowd', name: 'CategoryCrowd', component: () => import('@/views/category/Crowd.vue') },
{ path: 'settings', name: 'Settings', component: () => import('@/views/setting/Index.vue') },
{ path: 'links', name: 'LinkList', component: () => import('@/views/links/List.vue') },
{ path: 'links/edit/:id?', name: 'LinkEdit', component: () => import('@/views/links/Edit.vue') },
{ path: 'feedbacks', name: 'FeedbackList', component: () => import('@/views/feedback/List.vue') },
]
}
]
+4 -1
View File
@@ -11,7 +11,10 @@ export const useJokeStore = defineStore('joke', {
async fetchJokes(params) {
this.loading = true
try {
const res = await getJokes(params)
const cleanParams = Object.fromEntries(
Object.entries(params).filter(([_, v]) => v != null && v !== '')
)
const res = await getJokes(cleanParams)
this.jokes = res.items
this.total = res.total
} finally {
+39 -10
View File
@@ -10,14 +10,6 @@
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div class="stat-card">
<div class="stat-value">{{ stats.pending }}</div>
<div class="stat-label">待审核</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div class="stat-card">
@@ -26,6 +18,24 @@
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div class="stat-card">
<div class="stat-value">{{ stats.pending }}</div>
<div class="stat-label">待审核</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div class="stat-card">
<div class="stat-value">{{ stats.rejected }}</div>
<div class="stat-label">已拒绝</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="6">
<el-card>
<div class="stat-card">
@@ -34,6 +44,14 @@
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div class="stat-card">
<div class="stat-value">{{ stats.likes }}</div>
<div class="stat-label">总点赞</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
@@ -42,11 +60,22 @@
import { ref, onMounted } from 'vue'
import { getStats } from '@/api/joke'
const stats = ref({ total: 0, pending: 0, approved: 0, views: 0 })
const stats = ref({ total: 0, pending: 0, approved: 0, rejected: 0, views: 0, likes: 0 })
onMounted(async () => {
try {
const res = await getStats()
stats.value = res
stats.value = {
total: res.total_jokes || 0,
pending: res.pending_jokes || 0,
approved: res.approved_jokes || 0,
rejected: res.rejected_jokes || 0,
views: res.total_views || 0,
likes: res.total_likes || 0,
}
} catch (e) {
console.error('获取统计数据失败', e)
}
})
</script>
+54
View File
@@ -0,0 +1,54 @@
<template>
<div class="feedback-list">
<div class="header">
<h2>反馈管理</h2>
</div>
<el-table :data="feedbacks" v-loading="loading" style="margin-top: 20px">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" width="120" />
<el-table-column prop="email" label="邮箱" width="200" />
<el-table-column prop="content" label="内容" min-width="300" show-overflow-tooltip />
<el-table-column prop="created_at" label="提交时间" width="180" />
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getFeedbacks, deleteFeedback } from '@/api/feedback'
import { ElMessage, ElMessageBox } from 'element-plus'
const feedbacks = ref([])
const loading = ref(false)
const loadFeedbacks = async () => {
loading.value = true
try {
feedbacks.value = await getFeedbacks()
} finally {
loading.value = false
}
}
const handleDelete = async (id) => {
await ElMessageBox.confirm('确定删除?')
await deleteFeedback(id)
ElMessage.success('删除成功')
loadFeedbacks()
}
onMounted(() => loadFeedbacks())
</script>
<style scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
+5 -5
View File
@@ -8,13 +8,13 @@
<el-form-item label="内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="6" />
</el-form-item>
<el-form-item label="类型" prop="type_id">
<el-select v-model="form.type_id" placeholder="选择类型">
<el-form-item label="类型" prop="type_ids">
<el-select v-model="form.type_ids" multiple placeholder="选择类型(可多选)">
<el-option v-for="t in types" :key="t.id" :label="t.name" :value="t.id" />
</el-select>
</el-form-item>
<el-form-item label="人群" prop="crowd_id">
<el-select v-model="form.crowd_id" placeholder="选择人群">
<el-form-item label="人群" prop="crowd_ids">
<el-select v-model="form.crowd_ids" multiple placeholder="选择人群(可多选)">
<el-option v-for="c in crowds" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
@@ -46,7 +46,7 @@ const formRef = ref()
const isEdit = ref(false)
const form = reactive({
title: '', content: '', type_id: null, crowd_id: null, status: 'pending'
title: '', content: '', type_ids: [], crowd_ids: [], status: 'pending'
})
const types = ref([])
+10 -2
View File
@@ -7,8 +7,16 @@
<el-table :data="jokeStore.jokes" v-loading="jokeStore.loading" style="margin-top: 20px">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="title" label="标题" />
<el-table-column prop="type_name" label="类型" width="100" />
<el-table-column prop="crowd_name" label="人群" width="100" />
<el-table-column label="类型" width="140">
<template #default="{ row }">
<el-tag v-for="name in (row.type_names || [])" :key="name" size="small" style="margin:1px">{{ name }}</el-tag>
</template>
</el-table-column>
<el-table-column label="人群" width="140">
<template #default="{ row }">
<el-tag v-for="name in (row.crowd_names || [])" :key="name" size="small" type="success" style="margin:1px">{{ name }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'approved' ? 'success' : row.status === 'pending' ? 'warning' : 'danger'">
+60
View File
@@ -0,0 +1,60 @@
<template>
<div class="link-edit">
<h2>{{ isEdit ? '编辑友链' : '添加友链' }}</h2>
<el-form :model="form" ref="formRef" label-width="120px" style="margin-top: 20px; max-width: 600px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="URL" prop="url">
<el-input v-model="form.url" placeholder="https://" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="排序" prop="sort_order">
<el-input-number v-model="form.sort_order" :min="0" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave">保存</el-button>
<el-button @click="$router.back()">取消</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getLink, createLink, updateLink } from '@/api/link'
import { ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const formRef = ref()
const isEdit = ref(false)
const form = reactive({
name: '', url: '', description: '', sort_order: 0
})
const loadData = async () => {
if (route.params.id) {
isEdit.value = true
const res = await getLink(route.params.id)
Object.assign(form, res)
}
}
const handleSave = async () => {
if (isEdit.value) {
await updateLink(route.params.id, form)
ElMessage.success('更新成功')
} else {
await createLink(form)
ElMessage.success('创建成功')
}
router.push('/links')
}
onMounted(() => loadData())
</script>
+61
View File
@@ -0,0 +1,61 @@
<template>
<div class="link-list">
<div class="header">
<h2>友链管理</h2>
<el-button type="primary" @click="$router.push('/links/edit')">添加友链</el-button>
</div>
<el-table :data="links" v-loading="loading" style="margin-top: 20px">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="url" label="URL" min-width="200">
<template #default="{ row }">
<a :href="row.url" target="_blank">{{ row.url }}</a>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200" />
<el-table-column prop="sort_order" label="排序" width="80" />
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="$router.push(`/links/edit/${row.id}`)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getLinks, deleteLink } from '@/api/link'
import { ElMessage, ElMessageBox } from 'element-plus'
const links = ref([])
const loading = ref(false)
const loadLinks = async () => {
loading.value = true
try {
links.value = await getLinks()
} finally {
loading.value = false
}
}
const handleDelete = async (id) => {
await ElMessageBox.confirm('确定删除?')
await deleteLink(id)
ElMessage.success('删除成功')
loadLinks()
}
onMounted(() => loadLinks())
</script>
<style scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
+198
View File
@@ -0,0 +1,198 @@
<template>
<div class="setting-page">
<h2>AI 配置</h2>
<!-- AI 配置表单 -->
<el-card style="margin-top: 20px">
<template #header>
<span>AI 配置</span>
</template>
<el-form :model="form" label-width="120px">
<el-form-item label="提供商">
<el-select v-model="form.provider" style="width: 200px">
<el-option label="NVIDIA NIM" value="nvidia" />
<el-option label="OpenAI" value="openai" />
<el-option label="Anthropic" value="anthropic" />
</el-select>
</el-form-item>
<el-form-item label="API 地址">
<el-input v-model="form.api_base" placeholder="https://integrate.api.nvidia.com/v1" style="width: 400px" />
</el-form-item>
<el-form-item label="API 密钥">
<el-input v-model="form.api_key" type="password" show-password style="width: 400px" placeholder="nvapi-..." />
</el-form-item>
<el-form-item label="模型名称">
<el-input v-model="form.model_name" style="width: 300px" placeholder="nvidia/llama-3.1-nemotron-70b-instruct" />
</el-form-item>
<el-form-item label="温度">
<el-input-number v-model="form.temperature" :min="0" :max="2" :step="0.1" />
</el-form-item>
<el-form-item label="最大 Token">
<el-input-number v-model="form.max_tokens" :min="1" :max="8192" />
</el-form-item>
</el-form>
</el-card>
<!-- 爬虫配置 -->
<el-card style="margin-top: 20px">
<template #header>
<span>爬虫配置</span>
</template>
<el-form :model="form" label-width="120px">
<el-form-item label="启用爬虫">
<el-switch v-model="form.crawl_enabled" />
</el-form-item>
<el-form-item label="搜索关键词">
<el-input
v-model="form.crawl_keywords"
type="textarea"
:rows="2"
style="width: 400px"
placeholder="多个关键词用逗号分隔,如:冷笑话,段子,谐音梗"
/>
</el-form-item>
<el-form-item label="每轮页数">
<el-input-number v-model="form.max_pages_per_run" :min="1" :max="20" />
</el-form-item>
</el-form>
</el-card>
<div style="margin-top: 20px">
<el-button type="primary" @click="handleSave" :loading="saving">保存配置</el-button>
</div>
<!-- 历史配置 -->
<el-card style="margin-top: 20px">
<template #header>
<span>历史配置</span>
</template>
<el-table :data="allSettings" style="width: 100%">
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="provider" label="提供商" width="100" />
<el-table-column prop="model_name" label="模型" />
<el-table-column prop="api_base" label="API 地址" show-overflow-tooltip />
<el-table-column prop="is_active" label="激活" width="80">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
{{ row.is_active ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="warning" @click="handleToggle(row)" v-if="!row.is_active">激活</el-button>
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getSettings, getActiveSetting, createSetting, updateSetting, toggleSetting, deleteSetting } from '@/api/setting'
const form = ref({
provider: 'nvidia',
api_base: 'https://integrate.api.nvidia.com/v1',
api_key: '',
model_name: 'nvidia/llama-3.1-nemotron-70b-instruct',
temperature: 0.7,
max_tokens: 2048,
crawl_enabled: false,
crawl_keywords: '冷笑话,段子,谐音梗',
max_pages_per_run: 3,
})
const allSettings = ref([])
const saving = ref(false)
const editingId = ref(null)
const loadData = async () => {
try {
const active = await getActiveSetting()
editingId.value = active.id
form.value = {
provider: active.provider,
api_base: active.api_base,
api_key: active.api_key,
model_name: active.model_name,
temperature: active.temperature,
max_tokens: active.max_tokens,
crawl_enabled: active.crawl_enabled,
crawl_keywords: active.crawl_keywords || '',
max_pages_per_run: active.max_pages_per_run,
}
} catch (e) {
// 没有激活配置,使用默认值
}
try {
allSettings.value = await getSettings()
} catch (e) {
console.error(e)
}
}
const handleSave = async () => {
saving.value = true
try {
if (editingId.value) {
await updateSetting(editingId.value, form.value)
ElMessage.success('保存成功')
} else {
const created = await createSetting(form.value)
editingId.value = created.id
await toggleSetting(created.id) // 设为激活
ElMessage.success('创建并激活成功')
}
await loadData()
} catch (e) {
ElMessage.error('保存失败: ' + (e.message || ''))
} finally {
saving.value = false
}
}
const handleEdit = (row) => {
editingId.value = row.id
form.value = {
provider: row.provider,
api_base: row.api_base,
api_key: row.api_key,
model_name: row.model_name,
temperature: row.temperature,
max_tokens: row.max_tokens,
crawl_enabled: row.crawl_enabled,
crawl_keywords: row.crawl_keywords || '',
max_pages_per_run: row.max_pages_per_run,
}
}
const handleToggle = async (row) => {
await ElMessageBox.confirm('确定激活此配置?')
await toggleSetting(row.id)
ElMessage.success('激活成功')
await loadData()
}
const handleDelete = async (id) => {
await ElMessageBox.confirm('确定删除此配置?')
await deleteSetting(id)
ElMessage.success('删除成功')
if (editingId.value === id) {
editingId.value = null
}
await loadData()
}
onMounted(() => loadData())
</script>
<style scoped>
.setting-page { max-width: 900px }
</style>
+1 -1
View File
@@ -13,7 +13,7 @@ export default defineConfig({
port: 3001,
proxy: {
'/api': {
target: 'http://localhost:8000',
target: 'http://localhost:8001',
changeOrigin: true
}
}
+125
View File
@@ -0,0 +1,125 @@
# API 操作文档 - 笑话管理系统
## 基础信息
- **API 地址**: `http://<服务器IP>:8001`
- **默认管理员**: `admin` / `admin123`
- **认证方式**: JWT Bearer Token
---
## 一、登录获取 Token
```bash
curl -X POST http://<服务器IP>:8001/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "admin123"}'
```
**返回示例**
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer"
}
```
---
## 二、提交笑话
```bash
curl -X POST http://<服务器IP>:8001/api/admin/jokes \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <TOKEN>" \
-d '{
"title": "笑话标题",
"content": "笑话内容正文",
"type_id": 1,
"crowd_id": 2
}'
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `title` | string | 是 | 笑话标题 |
| `content` | string | 是 | 笑话正文 |
| `type_id` | int | 否 | 类型 ID(查分类列表获取) |
| `crowd_id` | int | 否 | 人群 ID(查分类列表获取) |
提交后 status 默认为 `pending`(待审核)。
---
## 三、查询分类 ID
```bash
# 查看所有类型
curl http://<服务器IP>:8001/api/categories/types
# 查看所有人群
curl http://<服务器IP>:8001/api/categories/crowds
```
---
## 四、完整脚本示例(批量提交)
```bash
#!/bin/bash
SERVER="http://192.168.1.10:8001"
# 登录获取 Token
TOKEN=$(curl -s -X POST "$SERVER/api/auth/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}' \
| python -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
echo "Token: $TOKEN"
# 批量提交笑话
jokes=(
'{"title":"笑话1","content":"内容1","type_id":1,"crowd_id":2}'
'{"title":"笑话2","content":"内容2","type_id":1,"crowd_id":null}'
)
for joke in "${jokes[@]}"; do
curl -X POST "$SERVER/api/admin/jokes" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "$joke"
echo ""
done
```
---
## 五、其他管理接口
### 查询笑话列表
```bash
curl -H "Authorization: Bearer <TOKEN>" \
"http://<服务器IP>:8001/api/admin/jokes?page=1&page_size=20&status=pending"
```
### 审核通过
```bash
curl -X PUT http://<服务器IP>:8001/api/admin/jokes/batch-approve \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <TOKEN>" \
-d '[1, 2, 3]'
```
### 删除笑话
```bash
curl -X DELETE http://<服务器IP>:8001/api/admin/jokes/1 \
-H "Authorization: Bearer <TOKEN>"
```
---
## 六、注意事项
1.`<服务器IP>` 替换为实际的服务器 IP 地址
2. 确保服务器防火墙已放行 **8001** 端口
3. Token 有过期时间,过期后需重新登录获取
Binary file not shown.
+4 -1
View File
@@ -1,5 +1,8 @@
from app.models.joke import Joke
from app.models.category import JokeType, JokeCrowd
from app.models.user import AdminUser
from app.models.setting import AiSetting
from app.models.link import Link
from app.models.feedback import Feedback
__all__ = ["Joke", "JokeType", "JokeCrowd", "AdminUser"]
__all__ = ["Joke", "JokeType", "JokeCrowd", "AdminUser", "AiSetting", "Link", "Feedback"]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
from sqlalchemy import Column, Integer, String, Text, DateTime
from sqlalchemy.sql import func
from app.database import Base
class Feedback(Base):
__tablename__ = "feedbacks"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(50), nullable=True)
email = Column(String(100), nullable=True)
content = Column(Text, nullable=False)
created_at = Column(DateTime, default=func.now())
+3
View File
@@ -12,6 +12,9 @@ class Joke(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(200), nullable=False)
content = Column(Text, nullable=False)
polished_content = Column(Text, nullable=True)
type_ids = Column(Text, nullable=True)
crowd_ids = Column(Text, nullable=True)
type_id = Column(Integer, ForeignKey("joke_types.id"), nullable=True)
crowd_id = Column(Integer, ForeignKey("joke_crowds.id"), nullable=True)
status = Column(String(20), default="pending")
+14
View File
@@ -0,0 +1,14 @@
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.sql import func
from app.database import Base
class Link(Base):
__tablename__ = "links"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False)
url = Column(String(500), nullable=False)
description = Column(String(200), nullable=True)
sort_order = Column(Integer, default=0)
created_at = Column(DateTime, default=func.now())
+23
View File
@@ -0,0 +1,23 @@
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, Text, func
from app.database import Base
class AiSetting(Base):
__tablename__ = "ai_settings"
id = Column(Integer, primary_key=True, autoincrement=True)
provider = Column(String(50), nullable=False, default="nvidia")
api_base = Column(String(500), nullable=False, default="https://integrate.api.nvidia.com/v1")
api_key = Column(String(500), nullable=False, default="")
model_name = Column(String(100), nullable=False, default="nvidia/llama-3.1-nemotron-70b-instruct")
temperature = Column(Float, nullable=False, default=0.7)
max_tokens = Column(Integer, nullable=False, default=2048)
crawl_enabled = Column(Boolean, nullable=False, default=False)
crawl_keywords = Column(Text, nullable=True, default="")
max_pages_per_run = Column(Integer, nullable=False, default=3)
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
+7
View File
@@ -0,0 +1,7 @@
from .jokes import router as jokes_router
from .categories import router as categories_router
from .auth import router as auth_router
from .admin import router as admin_router
from .settings import router as settings_router
__all__ = ["jokes_router", "categories_router", "auth_router", "admin_router", "settings_router"]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+164 -13
View File
@@ -1,3 +1,5 @@
import json
from fastapi import APIRouter, Depends, HTTPException, Header
from jose import JWTError, jwt
from sqlalchemy import func
@@ -8,8 +10,12 @@ from app.database import get_db
from app.models.joke import Joke
from app.models.category import JokeCrowd, JokeType
from app.models.user import AdminUser
from app.models.link import Link
from app.models.feedback import Feedback
from app.schemas.category import JokeCrowdCreate, JokeCrowdResponse, JokeTypeCreate, JokeTypeResponse
from app.schemas.joke import JokeCreate, JokeResponse, JokeUpdate, PaginatedJokeResponse
from app.schemas.link import LinkCreate, LinkResponse
from app.schemas.feedback import FeedbackResponse
router = APIRouter(prefix="/admin", tags=["管理后台"])
@@ -39,21 +45,48 @@ def get_current_admin_user(
return user
def joke_to_response(joke: Joke) -> JokeResponse:
def _parse_ids(raw) -> list[int]:
"""解析 DB 中的 JSON 列表字符串为 Python list"""
if not raw:
return []
if isinstance(raw, list):
return raw
try:
return json.loads(raw)
except Exception:
return []
def _get_type_names(db: Session, ids: list[int]) -> list[str]:
rows = db.query(JokeType).filter(JokeType.id.in_(ids)).all()
return [r.name for r in rows]
def _get_crowd_names(db: Session, ids: list[int]) -> list[str]:
rows = db.query(JokeCrowd).filter(JokeCrowd.id.in_(ids)).all()
return [r.name for r in rows]
def joke_to_response(joke: Joke, db: Session = None) -> JokeResponse:
"""Convert Joke model to JokeResponse schema."""
ids = _parse_ids(joke.type_ids)
crowd_ids = _parse_ids(joke.crowd_ids)
type_names = _get_type_names(db, ids) if db else []
crowd_names = _get_crowd_names(db, crowd_ids) if db else []
return JokeResponse(
id=joke.id,
title=joke.title,
content=joke.content,
type_id=joke.type_id,
crowd_id=joke.crowd_id,
polished_content=joke.polished_content,
type_ids=ids,
crowd_ids=crowd_ids,
status=joke.status,
view_count=joke.view_count,
like_count=joke.like_count,
created_at=joke.created_at,
updated_at=joke.updated_at,
type_name=joke.type.name if joke.type else None,
crowd_name=joke.crowd.name if joke.crowd else None,
type_names=type_names,
crowd_names=crowd_names,
)
@@ -67,6 +100,8 @@ def admin_list_jokes(
current_user: AdminUser = Depends(get_current_admin_user),
):
"""获取所有笑话(支持状态筛选)"""
# 限制 page_size 防止 DoS
page_size = max(1, min(page_size, 100))
query = db.query(Joke)
if status:
query = query.filter(Joke.status == status)
@@ -74,7 +109,7 @@ def admin_list_jokes(
offset = (page - 1) * page_size
jokes = query.order_by(Joke.created_at.desc()).offset(offset).limit(page_size).all()
return PaginatedJokeResponse(
items=[joke_to_response(j) for j in jokes],
items=[joke_to_response(j, db) for j in jokes],
total=total,
page=page,
page_size=page_size,
@@ -88,11 +123,29 @@ def admin_create_joke(
current_user: AdminUser = Depends(get_current_admin_user),
):
"""创建笑话"""
db_joke = Joke(**joke.model_dump())
data = joke.model_dump()
if data.get("type_ids") is not None:
data["type_ids"] = json.dumps(data["type_ids"])
if data.get("crowd_ids") is not None:
data["crowd_ids"] = json.dumps(data["crowd_ids"])
db_joke = Joke(**data)
db.add(db_joke)
db.commit()
db.refresh(db_joke)
return joke_to_response(db_joke)
return joke_to_response(db_joke, db)
@router.get("/jokes/{joke_id}", response_model=JokeResponse)
def admin_get_joke(
joke_id: int,
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""获取单个笑话"""
joke = db.query(Joke).filter(Joke.id == joke_id).first()
if not joke:
raise HTTPException(status_code=404, detail="笑话不存在")
return joke_to_response(joke, db)
@router.put("/jokes/{joke_id}", response_model=JokeResponse)
@@ -108,10 +161,12 @@ def admin_update_joke(
raise HTTPException(status_code=404, detail="笑话不存在")
update_data = joke.model_dump(exclude_unset=True)
for key, value in update_data.items():
if key in ("type_ids", "crowd_ids") and value is not None:
value = json.dumps(value)
setattr(db_joke, key, value)
db.commit()
db.refresh(db_joke)
return joke_to_response(db_joke)
return joke_to_response(db_joke, db)
@router.delete("/jokes/{joke_id}")
@@ -152,12 +207,14 @@ def admin_stats(
total_jokes = db.query(Joke).count()
approved_jokes = db.query(Joke).filter(Joke.status == "approved").count()
pending_jokes = db.query(Joke).filter(Joke.status == "pending").count()
rejected_jokes = db.query(Joke).filter(Joke.status == "rejected").count()
total_views = db.query(Joke).with_entities(func.sum(Joke.view_count)).scalar() or 0
total_likes = db.query(Joke).with_entities(func.sum(Joke.like_count)).scalar() or 0
return {
"total_jokes": total_jokes,
"approved_jokes": approved_jokes,
"pending_jokes": pending_jokes,
"rejected_jokes": rejected_jokes,
"total_views": total_views,
"total_likes": total_likes,
}
@@ -209,8 +266,11 @@ def admin_delete_type(
db_type = db.query(JokeType).filter(JokeType.id == type_id).first()
if not db_type:
raise HTTPException(status_code=404, detail="类型不存在")
# Check if there are jokes using this type
joke_count = db.query(Joke).filter(Joke.type_id == type_id).count()
# Check both old single-field and new array-field associations
old_count = db.query(Joke).filter(Joke.type_id == type_id).count()
all_jokes = db.query(Joke.type_ids).filter(Joke.type_ids.isnot(None)).all()
new_count = sum(1 for (raw,) in all_jokes if _parse_ids(raw) and type_id in _parse_ids(raw))
joke_count = old_count + new_count
if joke_count > 0:
raise HTTPException(status_code=400, detail=f"{joke_count} 条笑话使用此类型,无法删除")
db.delete(db_type)
@@ -263,10 +323,101 @@ def admin_delete_crowd(
db_crowd = db.query(JokeCrowd).filter(JokeCrowd.id == crowd_id).first()
if not db_crowd:
raise HTTPException(status_code=404, detail="人群分类不存在")
# Check if there are jokes using this crowd
joke_count = db.query(Joke).filter(Joke.crowd_id == crowd_id).count()
# Check both old single-field and new array-field associations
old_count = db.query(Joke).filter(Joke.crowd_id == crowd_id).count()
all_jokes = db.query(Joke.crowd_ids).filter(Joke.crowd_ids.isnot(None)).all()
new_count = sum(1 for (raw,) in all_jokes if _parse_ids(raw) and crowd_id in _parse_ids(raw))
joke_count = old_count + new_count
if joke_count > 0:
raise HTTPException(status_code=400, detail=f"{joke_count} 条笑话使用此人群,无法删除")
db.delete(db_crowd)
db.commit()
return {"message": "删除成功"}
# ============================================================
# 友情链接管理
# ============================================================
@router.get("/links", response_model=list[LinkResponse])
def admin_list_links(
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""获取所有友情链接"""
return db.query(Link).order_by(Link.sort_order, Link.id).all()
@router.post("/links", response_model=LinkResponse)
def admin_create_link(
link: LinkCreate,
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""创建友情链接"""
db_link = Link(**link.model_dump())
db.add(db_link)
db.commit()
db.refresh(db_link)
return db_link
@router.put("/links/{link_id}", response_model=LinkResponse)
def admin_update_link(
link_id: int,
link: LinkCreate,
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""更新友情链接"""
db_link = db.query(Link).filter(Link.id == link_id).first()
if not db_link:
raise HTTPException(status_code=404, detail="链接不存在")
for key, value in link.model_dump().items():
setattr(db_link, key, value)
db.commit()
db.refresh(db_link)
return db_link
@router.delete("/links/{link_id}")
def admin_delete_link(
link_id: int,
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""删除友情链接"""
db_link = db.query(Link).filter(Link.id == link_id).first()
if not db_link:
raise HTTPException(status_code=404, detail="链接不存在")
db.delete(db_link)
db.commit()
return {"message": "删除成功"}
# ============================================================
# 反馈建议管理
# ============================================================
@router.get("/feedbacks", response_model=list[FeedbackResponse])
def admin_list_feedbacks(
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""获取所有反馈建议"""
return db.query(Feedback).order_by(Feedback.created_at.desc()).all()
@router.delete("/feedbacks/{feedback_id}")
def admin_delete_feedback(
feedback_id: int,
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""删除反馈"""
db_feedback = db.query(Feedback).filter(Feedback.id == feedback_id).first()
if not db_feedback:
raise HTTPException(status_code=404, detail="反馈不存在")
db.delete(db_feedback)
db.commit()
return {"message": "删除成功"}
+18
View File
@@ -0,0 +1,18 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.feedback import Feedback
from app.schemas.feedback import FeedbackCreate, FeedbackResponse
router = APIRouter(tags=["反馈建议"])
@router.post("/feedback", response_model=FeedbackResponse)
def create_feedback(feedback: FeedbackCreate, db: Session = Depends(get_db)):
"""公开:提交反馈建议"""
db_feedback = Feedback(**feedback.model_dump())
db.add(db_feedback)
db.commit()
db.refresh(db_feedback)
return db_feedback
+17 -3
View File
@@ -76,8 +76,12 @@ def generate_joke(
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
def _parse_response(raw: str) -> GenerateResponse:
def _parse_response(raw: str | None) -> GenerateResponse:
"""解析 AI 返回内容,提取标题和内容"""
# 防御:处理空或 None 输入
if not raw or not raw.strip():
raise ValueError("AI 返回内容为空")
title = ""
content = raw
@@ -86,16 +90,26 @@ def _parse_response(raw: str) -> GenerateResponse:
line = line.strip()
if line.startswith("标题:") or line.startswith("标题:"):
title = line.split("", 1)[-1].split(":", 1)[-1].strip()
content = content.replace(line, "").strip()
# 只替换这一行,不要 replace 全局
lines = content.split("\n")
for i, l in enumerate(lines):
if l.strip() == line:
lines[i] = ""
break
content = "\n".join(lines).strip()
break
# 如果没有提取到标题,取第一行或前20字
# 如果没有提取到标题,取第一行
if not title:
first_line = raw.split("\n")[0].strip()
if first_line.startswith("标题"):
first_line = first_line.split("", 1)[-1].split(":", 1)[-1].strip()
title = first_line[:30] if len(first_line) > 30 else first_line
# 防御:content 不能为空
if not content.strip():
content = "(内容生成失败,请重新生成)"
return GenerateResponse(
title=title or "生成的笑话",
content=content.strip(),
+110 -17
View File
@@ -1,3 +1,4 @@
import json
import random
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -6,26 +7,56 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.models.joke import Joke
from app.models.category import JokeCrowd, JokeType
from app.schemas.joke import JokeResponse, PaginatedJokeResponse
router = APIRouter(prefix="/jokes", tags=["笑话"])
def joke_to_response(joke: Joke) -> JokeResponse:
def _parse_ids(raw) -> list[int]:
if not raw:
return []
if isinstance(raw, list):
return raw
try:
return json.loads(raw)
except Exception:
return []
def _get_type_names(db: Session, ids: list[int]) -> list[str]:
if not ids:
return []
rows = db.query(JokeType).filter(JokeType.id.in_(ids)).all()
return [r.name for r in rows]
def _get_crowd_names(db: Session, ids: list[int]) -> list[str]:
if not ids:
return []
rows = db.query(JokeCrowd).filter(JokeCrowd.id.in_(ids)).all()
return [r.name for r in rows]
def joke_to_response(joke: Joke, db: Session = None) -> JokeResponse:
"""Convert Joke model to JokeResponse schema."""
ids = _parse_ids(joke.type_ids)
crowd_ids = _parse_ids(joke.crowd_ids)
type_names = _get_type_names(db, ids) if db else []
crowd_names = _get_crowd_names(db, crowd_ids) if db else []
return JokeResponse(
id=joke.id,
title=joke.title,
content=joke.content,
type_id=joke.type_id,
crowd_id=joke.crowd_id,
type_ids=ids,
crowd_ids=crowd_ids,
status=joke.status,
view_count=joke.view_count,
like_count=joke.like_count,
created_at=joke.created_at,
updated_at=joke.updated_at,
type_name=joke.type.name if joke.type else None,
crowd_name=joke.crowd.name if joke.crowd else None,
type_names=type_names,
crowd_names=crowd_names,
)
@@ -33,24 +64,46 @@ def joke_to_response(joke: Joke) -> JokeResponse:
def list_jokes(
page: int = 1,
page_size: int = 20,
type_id: int | None = None,
crowd_id: int | None = None,
type_ids: str | None = Query(None, description="逗号分隔的类型 ID,如 1,3,5"),
crowd_ids: str | None = Query(None, description="逗号分隔的人群 ID,如 2,4"),
db: Session = Depends(get_db),
):
"""获取笑话列表(仅返回已审核通过的笑话)"""
query = db.query(Joke).filter(Joke.status == "approved")
if type_id is not None:
query = query.filter(Joke.type_id == type_id)
if crowd_id is not None:
query = query.filter(Joke.crowd_id == crowd_id)
# 支持多选过滤:逗号分隔的 ID
if type_ids:
filter_set = {int(x.strip()) for x in type_ids.split(",") if x.strip().isdigit()}
if filter_set:
# 兼容旧单值字段 type_id
old_filter = Joke.type_id.in_(filter_set)
# 兼容新数组字段 type_ids(JSON 包含任一)
new_filter = [
Joke.type_ids.contains(str(tid)) for tid in filter_set
]
combined = old_filter
for nf in new_filter:
combined = combined | nf
query = query.filter(combined)
if crowd_ids:
filter_set = {int(x.strip()) for x in crowd_ids.split(",") if x.strip().isdigit()}
if filter_set:
old_filter = Joke.crowd_id.in_(filter_set)
new_filter = [
Joke.crowd_ids.contains(str(cid)) for cid in filter_set
]
combined = old_filter
for nf in new_filter:
combined = combined | nf
query = query.filter(combined)
total = query.count()
offset = (page - 1) * page_size
jokes = query.order_by(Joke.created_at.desc()).offset(offset).limit(page_size).all()
return PaginatedJokeResponse(
items=[joke_to_response(j) for j in jokes],
items=[joke_to_response(j, db) for j in jokes],
total=total,
page=page,
page_size=page_size,
@@ -60,19 +113,59 @@ def list_jokes(
@router.get("/{joke_id}", response_model=JokeResponse)
def get_joke(joke_id: int, db: Session = Depends(get_db)):
"""获取单条笑话详情"""
joke = db.query(Joke).filter(Joke.id == joke_id).first()
# 只返回已审核通过的笑话
joke = db.query(Joke).filter(
Joke.id == joke_id,
Joke.status == "approved"
).first()
if not joke:
raise HTTPException(status_code=404, detail="笑话不存在")
# 增加浏览次数
joke.view_count += 1
# 使用原子更新避免并发竞态
db.query(Joke).filter(Joke.id == joke_id).update({Joke.view_count: Joke.view_count + 1})
db.commit()
return joke_to_response(joke)
# 重新查询获取更新后的数据
db.refresh(joke)
return joke_to_response(joke, db)
@router.get("/random", response_model=JokeResponse)
def get_random_joke(db: Session = Depends(get_db)):
"""随机获取一条已审核通过的笑话"""
# 使用效率更高的方式:随机 ID 取模
max_id = db.query(func.max(Joke.id)).filter(Joke.status == "approved").scalar()
if not max_id:
raise HTTPException(status_code=404, detail="暂无笑话")
# 尝试最多 10 次找到有效笑话
for _ in range(10):
random_id = random.randint(1, max_id)
joke = db.query(Joke).filter(
Joke.id >= random_id,
Joke.status == "approved"
).first()
if joke:
return joke_to_response(joke, db)
# 兜底:全表随机
joke = db.query(Joke).filter(Joke.status == "approved").order_by(func.random()).first()
if not joke:
raise HTTPException(status_code=404, detail="暂无笑话")
return joke_to_response(joke)
return joke_to_response(joke, db)
@router.post("/{joke_id}/like")
def like_joke(joke_id: int, db: Session = Depends(get_db)):
"""为笑话点赞(仅允许已审核通过的笑话)"""
# 先检查笑话是否存在且已审核
joke = db.query(Joke).filter(
Joke.id == joke_id,
Joke.status == "approved"
).first()
if not joke:
raise HTTPException(status_code=404, detail="笑话不存在")
# 使用原子更新避免并发竞态
db.query(Joke).filter(Joke.id == joke_id).update({Joke.like_count: Joke.like_count + 1})
db.commit()
# 获取更新后的值
db.refresh(joke)
return {"message": "点赞成功", "like_count": joke.like_count}
+14
View File
@@ -0,0 +1,14 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.link import Link
from app.schemas.link import LinkCreate, LinkResponse
router = APIRouter(tags=["友情链接"])
@router.get("/links", response_model=list[LinkResponse])
def list_links(db: Session = Depends(get_db)):
"""公开:获取所有友情链接"""
return db.query(Link).order_by(Link.sort_order, Link.id).all()
+96
View File
@@ -0,0 +1,96 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.setting import AiSetting
from app.schemas.setting import AiSettingCreate
from app.routers.admin import get_current_admin_user
from app.models.user import AdminUser
router = APIRouter(prefix="/admin/settings", tags=["AI设置"])
@router.get("", response_model=list)
def list_settings(
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""列出所有 AI 配置"""
return db.query(AiSetting).order_by(AiSetting.created_at.desc()).all()
@router.get("/active")
def get_active_setting(
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""获取当前激活的 AI 配置(爬虫调用,无需用户认证,token 校验仍保留)"""
setting = db.query(AiSetting).filter(AiSetting.is_active == True).first()
if not setting:
raise HTTPException(status_code=404, detail="未找到激活的 AI 配置")
return setting
@router.post("")
def create_setting(
setting: AiSettingCreate,
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""新建 AI 配置"""
db_setting = AiSetting(**setting.model_dump())
db.add(db_setting)
db.commit()
db.refresh(db_setting)
return db_setting
@router.put("/{setting_id}")
def update_setting(
setting_id: int,
setting: AiSettingCreate,
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""更新 AI 配置"""
db_setting = db.query(AiSetting).filter(AiSetting.id == setting_id).first()
if not db_setting:
raise HTTPException(status_code=404, detail="配置不存在")
for key, value in setting.model_dump().items():
setattr(db_setting, key, value)
db.commit()
db.refresh(db_setting)
return db_setting
@router.put("/{setting_id}/toggle")
def toggle_setting(
setting_id: int,
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""切换激活状态(只能有一个活跃)"""
db_setting = db.query(AiSetting).filter(AiSetting.id == setting_id).first()
if not db_setting:
raise HTTPException(status_code=404, detail="配置不存在")
# 先全部设为非活跃
db.query(AiSetting).update({AiSetting.is_active: False})
db_setting.is_active = True
db.commit()
return {"message": "已激活", "id": setting_id}
@router.delete("/{setting_id}")
def delete_setting(
setting_id: int,
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""删除 AI 配置"""
db_setting = db.query(AiSetting).filter(AiSetting.id == setting_id).first()
if not db_setting:
raise HTTPException(status_code=404, detail="配置不存在")
db.delete(db_setting)
db.commit()
return {"message": "删除成功"}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
from datetime import datetime
from pydantic import BaseModel
class FeedbackCreate(BaseModel):
name: str | None = None
email: str | None = None
content: str
class FeedbackResponse(FeedbackCreate):
id: int
created_at: datetime
class Config:
from_attributes = True
+18
View File
@@ -0,0 +1,18 @@
from datetime import datetime
from pydantic import BaseModel
class LinkCreate(BaseModel):
name: str
url: str
description: str | None = None
sort_order: int = 0
class LinkResponse(LinkCreate):
id: int
created_at: datetime
class Config:
from_attributes = True
+17
View File
@@ -0,0 +1,17 @@
from pydantic import BaseModel, ConfigDict, Field
class AiSettingBase(BaseModel):
provider: str = Field(default="nvidia")
api_base: str = Field(default="https://integrate.api.nvidia.com/v1")
api_key: str = Field(default="")
model_name: str = Field(default="nvidia/llama-3.1-nemotron-70b-instruct")
temperature: float = Field(default=0.7, ge=0, le=2)
max_tokens: int = Field(default=2048, ge=1)
crawl_enabled: bool = Field(default=False)
crawl_keywords: str = Field(default="")
max_pages_per_run: int = Field(default=3, ge=1)
class AiSettingCreate(AiSettingBase):
pass
BIN
View File
Binary file not shown.
+39
View File
@@ -0,0 +1,39 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import API_TITLE, API_VERSION
from app.routers import jokes_router, categories_router, auth_router, admin_router, settings_router
from app.database import Base, engine
from app.models.setting import AiSetting # 注册模型,确保 create_all 能看到
# 创建新表(如果不存在)
Base.metadata.create_all(bind=engine)
# Create FastAPI application
app = FastAPI(title=API_TITLE, version=API_VERSION)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount routers
app.include_router(jokes_router, prefix="/api")
app.include_router(categories_router, prefix="/api")
app.include_router(auth_router, prefix="/api")
app.include_router(admin_router, prefix="/api")
app.include_router(settings_router, prefix="/api")
@app.get("/")
def root():
return {"message": "欢迎使用笑话大全 API"}
@app.get("/health")
def health_check():
return {"status": "healthy"}
+3
View File
@@ -5,3 +5,6 @@ pydantic==2.5.3
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
openai==1.12.0
crawl4ai==0.3.0
httpx==0.27.0
+501
View File
@@ -0,0 +1,501 @@
INFO: Will watch for changes in these directories: ['D:\\bwstudio\\joke\\api']
INFO: Uvicorn running on http://127.0.0.1:8001 (Press CTRL+C to quit)
INFO: Started reloader process [2236] using WatchFiles
INFO: Started server process [1824]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: 127.0.0.1:61949 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:61961 - "POST /api/auth/login HTTP/1.1" 200 OK
INFO: 127.0.0.1:61964 - "GET /api/admin/jokes?page=1&page_size=3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62046 - "GET / HTTP/1.1" 200 OK
INFO: 127.0.0.1:62046 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:62086 - "GET /api/admin/stats HTTP/1.1" 200 OK
INFO: 127.0.0.1:62088 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62090 - "GET /api/admin/stats HTTP/1.1" 200 OK
INFO: 127.0.0.1:62151 - "GET /api/admin/stats HTTP/1.1" 200 OK
INFO: 127.0.0.1:62154 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62158 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62161 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62164 - "GET /api/admin/jokes/1278 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62167 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62173 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62176 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62179 - "GET /api/admin/jokes/1275 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62182 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62198 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62201 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62204 - "GET /api/admin/jokes/1273 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62208 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62213 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62216 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62219 - "GET /api/admin/jokes/1279 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62223 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62230 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62233 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62236 - "GET /api/admin/jokes/1274 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62239 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62242 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62245 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62261 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62264 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62267 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62270 - "GET /api/admin/jokes/1262 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62273 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62283 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62286 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62289 - "GET /api/admin/jokes/1265 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62292 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62295 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62298 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62302 - "GET /api/admin/settings/active HTTP/1.1" 200 OK
INFO: 127.0.0.1:62304 - "GET /api/admin/settings HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\protocols\http\httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\middleware\proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 186, in __call__
raise exc
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\cors.py", line 83, in __call__
await self.app(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
raise exc
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 782, in app
await route.handle(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 297, in handle
await self.app(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
raise exc
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 315, in app
content = await serialize_response(
^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 160, in serialize_response
return field.serialize(
^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\_compat.py", line 147, in serialize
return self._type_adapter.dump_python(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\pydantic\type_adapter.py", line 607, in dump_python
return self.serializer.to_python(
^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.PydanticSerializationError: Unable to serialize unknown type: <class 'app.models.setting.AiSetting'>
INFO: 127.0.0.1:62308 - "GET /api/admin/stats HTTP/1.1" 200 OK
INFO: 127.0.0.1:62324 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62327 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62330 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62356 - "GET /api/admin/settings/active HTTP/1.1" 200 OK
INFO: 127.0.0.1:62359 - "GET /api/admin/settings HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\protocols\http\httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\middleware\proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 186, in __call__
raise exc
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\cors.py", line 83, in __call__
await self.app(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
raise exc
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 782, in app
await route.handle(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 297, in handle
await self.app(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
raise exc
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 315, in app
content = await serialize_response(
^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 160, in serialize_response
return field.serialize(
^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\_compat.py", line 147, in serialize
return self._type_adapter.dump_python(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\pydantic\type_adapter.py", line 607, in dump_python
return self.serializer.to_python(
^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.PydanticSerializationError: Unable to serialize unknown type: <class 'app.models.setting.AiSetting'>
INFO: 127.0.0.1:62362 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62365 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62368 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62371 - "GET /api/admin/stats HTTP/1.1" 200 OK
INFO: 127.0.0.1:62383 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62386 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62389 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62392 - "GET /api/admin/settings/active HTTP/1.1" 200 OK
INFO: 127.0.0.1:62395 - "GET /api/admin/settings HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\protocols\http\httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\middleware\proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 186, in __call__
raise exc
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\cors.py", line 83, in __call__
await self.app(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
raise exc
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 782, in app
await route.handle(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 297, in handle
await self.app(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
raise exc
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 315, in app
content = await serialize_response(
^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 160, in serialize_response
return field.serialize(
^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\_compat.py", line 147, in serialize
return self._type_adapter.dump_python(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\pydantic\type_adapter.py", line 607, in dump_python
return self.serializer.to_python(
^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.PydanticSerializationError: Unable to serialize unknown type: <class 'app.models.setting.AiSetting'>
INFO: 127.0.0.1:62398 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62401 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62404 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62407 - "GET /api/admin/stats HTTP/1.1" 200 OK
INFO: 127.0.0.1:62414 - "GET /api/admin/stats HTTP/1.1" 200 OK
INFO: 127.0.0.1:62419 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62422 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62425 - "GET /api/admin/stats HTTP/1.1" 200 OK
INFO: 127.0.0.1:62429 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62432 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62435 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62438 - "GET /api/admin/stats HTTP/1.1" 200 OK
INFO: 127.0.0.1:62441 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62444 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62447 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62451 - "GET /api/admin/settings/active HTTP/1.1" 200 OK
INFO: 127.0.0.1:62453 - "GET /api/admin/settings HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\protocols\http\httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\middleware\proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 186, in __call__
raise exc
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\cors.py", line 83, in __call__
await self.app(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
raise exc
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 782, in app
await route.handle(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 297, in handle
await self.app(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
raise exc
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 315, in app
content = await serialize_response(
^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 160, in serialize_response
return field.serialize(
^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\_compat.py", line 147, in serialize
return self._type_adapter.dump_python(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\pydantic\type_adapter.py", line 607, in dump_python
return self.serializer.to_python(
^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.PydanticSerializationError: Unable to serialize unknown type: <class 'app.models.setting.AiSetting'>
INFO: 127.0.0.1:62456 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62459 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62462 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62465 - "GET /api/admin/stats HTTP/1.1" 200 OK
INFO: 127.0.0.1:62468 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62471 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62474 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62477 - "GET /api/admin/settings/active HTTP/1.1" 200 OK
INFO: 127.0.0.1:62480 - "GET /api/admin/settings HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\protocols\http\httptools_impl.py", line 419, in run_asgi
result = await app( # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\uvicorn\middleware\proxy_headers.py", line 84, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\applications.py", line 1054, in __call__
await super().__call__(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\applications.py", line 123, in __call__
await self.middleware_stack(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 186, in __call__
raise exc
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\cors.py", line 83, in __call__
await self.app(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\middleware\exceptions.py", line 62, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
raise exc
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 762, in __call__
await self.middleware_stack(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 782, in app
await route.handle(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 297, in handle
await self.app(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 77, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 64, in wrapped_app
raise exc
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\_exception_handler.py", line 53, in wrapped_app
await app(scope, receive, sender)
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\starlette\routing.py", line 72, in app
response = await func(request)
^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 315, in app
content = await serialize_response(
^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\routing.py", line 160, in serialize_response
return field.serialize(
^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\fastapi\_compat.py", line 147, in serialize
return self._type_adapter.dump_python(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\PF\AppData\Roaming\Python\Python311\site-packages\pydantic\type_adapter.py", line 607, in dump_python
return self.serializer.to_python(
^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.PydanticSerializationError: Unable to serialize unknown type: <class 'app.models.setting.AiSetting'>
INFO: 127.0.0.1:62483 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62486 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62489 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62492 - "GET /api/admin/stats HTTP/1.1" 200 OK
INFO: 127.0.0.1:62883 - "GET /api/admin/stats HTTP/1.1" 200 OK
INFO: 127.0.0.1:62885 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62887 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62889 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62892 - "GET /api/admin/jokes/1279 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62895 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62898 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62901 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62904 - "GET /api/admin/jokes/1279 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62907 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62992 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:62994 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:62997 - "GET /api/jokes/?page=1&page_size=30 HTTP/1.1" 200 OK
INFO: 127.0.0.1:62999 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63002 - "GET /api/jokes/1275 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63005 - "GET /api/jokes/?page=1&page_size=50 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63008 - "GET /api/jokes/1246 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63011 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63014 - "GET /api/jokes/?page=1&page_size=20&type_ids=1 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63017 - "GET /api/jokes/1279 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63021 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63034 - "GET /api/jokes/?page=1&page_size=20&type_ids=12 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63037 - "GET /api/jokes/?page=1&page_size=20&type_ids=12,1 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63040 - "GET /api/jokes/1279 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63043 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63046 - "GET /api/jokes/1279 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63049 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63052 - "GET /api/jokes/1279 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63055 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63059 - "GET /api/jokes/1277 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63062 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
WARNING: WatchFiles detected changes in 'app\models\link.py'. Reloading...
INFO: 127.0.0.1:63122 - "GET /api/jokes/?page=2&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63125 - "GET /api/jokes/?page=3&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63128 - "GET /api/jokes/?page=4&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63131 - "GET /api/jokes/?page=5&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63134 - "GET /api/jokes/?page=7&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63138 - "GET /api/jokes/1140 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63141 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63144 - "GET /api/jokes/?page=1&page_size=20&type_ids=4 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63147 - "GET /api/jokes/923 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63150 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63161 - "GET /api/jokes/?page=1&page_size=20&type_ids=3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63164 - "GET /api/jokes/?page=1&page_size=20&type_ids=3,4 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63167 - "GET /api/jokes/?page=1&page_size=20&type_ids=3,4,5 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63170 - "GET /api/jokes/?page=1&page_size=20&type_ids=3,4,5,6 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63173 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63176 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6,3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63179 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6,3,2 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63182 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6,3,2,1 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63185 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6,3,2,1,8 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63188 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6,3,2,1,8,7 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63191 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6,3,2,1,8,7 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63194 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6,3,2,1,8,7 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63197 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6,3,2,1,8,7 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63200 - "GET /api/jokes/?page=1&page_size=20&type_ids=4,5,6,3,2,1,8,7 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63204 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63208 - "GET /api/jokes/1279 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63211 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63214 - "GET /api/jokes/?page=1&page_size=20&type_ids=3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63217 - "GET /api/jokes/?page=1&page_size=20&type_ids=3,4 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63220 - "GET /api/jokes/?page=1&page_size=20&type_ids=3,4,5 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63223 - "GET /api/jokes/?page=1&page_size=20&type_ids=3,4,5,6 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63226 - "GET /api/jokes/1251 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63229 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63232 - "GET /api/jokes/?page=1&page_size=20&type_ids=2 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63235 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63238 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3,4 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63241 - "GET /api/jokes/?page=1&page_size=20&crowd_ids=15 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63244 - "GET /api/jokes/?page=1&page_size=20&crowd_ids=15,16 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63264 - "GET /api/jokes/?page=1&page_size=20&type_ids=2 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63267 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63270 - "GET /api/jokes/1273 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63273 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63276 - "GET /api/jokes/1277 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63279 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63295 - "GET /api/jokes/?page=2&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63298 - "GET /api/jokes/?page=3&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63301 - "GET /api/jokes/?page=1&page_size=20&type_ids=1 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63304 - "GET /api/jokes/?page=1&page_size=20&type_ids=1,2 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63307 - "GET /api/jokes/?page=1&page_size=20&type_ids=1,2,3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63310 - "GET /api/jokes/?page=1&page_size=20&type_ids=1,2,3,4 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63313 - "GET /api/jokes/?page=1&page_size=20&type_ids=1,2,3,4,5 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63316 - "GET /api/jokes/?page=1&page_size=20&type_ids=1,2,3,4,5,6 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63319 - "GET /api/jokes/?page=1&page_size=20&type_ids=1,2,3,4,5,6,7 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63322 - "GET /api/jokes/?page=1&page_size=20&type_ids=1,2,3,4,5,6,7,8 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63325 - "GET /api/jokes/?page=1&page_size=20&type_ids=1,2,3,4,5,6,7,8,9 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63328 - "GET /api/jokes/?page=1&page_size=20&type_ids=1,2,3,4,5,6,7,8,9,10 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63331 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3,4,5,6,7,8,9,10 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63334 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3,4,5,6,7,8,9 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63337 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3,4,5,6,7,8 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63340 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3,4,5,6,7 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63343 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3,4,5,6 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63346 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3,4,5 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63349 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3,4 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63352 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63369 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63371 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:63373 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:63376 - "GET /api/admin/jokes?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63379 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:63390 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:63392 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:63394 - "GET /api/jokes/?page=1&page_size=30 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63396 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63427 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:63446 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:63448 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:63452 - "GET /api/jokes/?page=1&page_size=20&type_ids=2,3 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63450 - "GET /api/jokes/?page=1&page_size=30 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63454 - "GET /api/jokes/1263 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63456 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63466 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:63468 - "GET /api/admin/feedbacks HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63471 - "GET /api/admin/links HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63473 - "GET /api/admin/feedbacks HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63476 - "GET /api/admin/links HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63479 - "GET /api/admin/feedbacks HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63482 - "GET /api/admin/links HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63486 - "GET /api/admin/links HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63495 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:63498 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:63503 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63501 - "GET /api/jokes/?page=1&page_size=30 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63508 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:63511 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:63514 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:63517 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:63520 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63524 - "GET /api/jokes/?page=1&page_size=30 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63565 - "GET /api/admin/feedbacks HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63568 - "GET /api/admin/links HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63571 - "GET /api/admin/links HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63574 - "GET /api/admin/links HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63577 - "GET /api/admin/feedbacks HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63580 - "GET /api/admin/links HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63583 - "GET /api/admin/feedbacks HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63592 - "GET /api/categories/types HTTP/1.1" 200 OK
INFO: 127.0.0.1:63594 - "GET /api/categories/crowds HTTP/1.1" 200 OK
INFO: 127.0.0.1:63598 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63596 - "GET /api/jokes/?page=1&page_size=30 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63607 - "GET /api/links HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63609 - "GET /api/links HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:63612 - "GET /api/jokes/?page=1&page_size=20 HTTP/1.1" 200 OK
INFO: 127.0.0.1:63615 - "GET /api/links HTTP/1.1" 404 Not Found
+1
View File
@@ -0,0 +1 @@
"""AI 提示词模板"""
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+71
View File
@@ -0,0 +1,71 @@
"""LLM 处理:调用 NVIDIA NIMOpenAI 兼容 API)进行笑话提取、改写和分类。"""
import json
import os
from openai import OpenAI
class AiService:
def __init__(self, api_base: str, api_key: str, model_name: str, temperature: float = 0.7, max_tokens: int = 2048):
self.client = OpenAI(base_url=api_base, api_key=api_key)
self.model = model_name
self.temperature = temperature
self.max_tokens = max_tokens
def extract_jokes(self, page_content: str, known_types: list[str], known_crowds: list[str]) -> list[dict]:
"""从页面内容中提取笑话,返回结构化数据。"""
from crawler.prompts import EXTRACTION_SYSTEM_PROMPT, EXTRACTION_USER_PROMPT
user_prompt = EXTRACTION_USER_PROMPT.format(
page_content=page_content[:8000],
known_types=", ".join(known_types),
known_crowds=", ".join(known_crowds),
)
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": EXTRACTION_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=self.temperature,
max_tokens=self.max_tokens,
)
raw = response.choices[0].message.content
return self._parse_json(raw)
def rewrite_joke(self, content: str) -> str:
"""润色单条笑话内容。"""
from crawler.prompts import REWRITE_SYSTEM_PROMPT, REWRITE_USER_PROMPT
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": REWRITE_SYSTEM_PROMPT},
{"role": "user", "content": REWRITE_USER_PROMPT.format(content=content)},
],
temperature=0.8,
max_tokens=500,
)
return response.choices[0].message.content.strip()
def _parse_json(self, raw: str) -> list[dict]:
"""安全解析 LLM 返回的 JSON。"""
try:
data = json.loads(raw)
if isinstance(data, dict):
jokes = data.get("jokes") or data.get("items") or [data]
else:
jokes = data
return [j for j in jokes if isinstance(j, dict) and j.get("content")]
except json.JSONDecodeError:
# 尝试提取 markdown 代码块
if "```json" in raw:
raw = raw.split("```json")[1].split("```")[0]
elif "```" in raw:
raw = raw.split("```")[1].split("```")[0]
try:
return json.loads(raw.strip())
except Exception:
return []
+439
View File
@@ -0,0 +1,439 @@
"""统一网页获取:搜索笑话站点 + 深度翻页抓取。"""
import asyncio
import os
import re
import sys
from urllib.parse import quote, urljoin, urlparse
# Windows 下禁用 rich 控制台输出,避免 GBK 编码错误
if sys.platform == "win32":
os.environ["PYTHONIOENCODING"] = "utf-8"
os.environ["TERM"] = "dumb"
try:
from crawl4ai import AsyncWebCrawler
from crawl4ai.async_configs import CrawlerRunConfig, BrowserConfig
HAS_CRAWL4AI = True
except ImportError:
HAS_CRAWL4AI = False
print("[!] crawl4ai 未安装,将使用 requests 替代(不支持 JS 渲染)")
import httpx
class CrawlerService:
def __init__(self, headless: bool = True):
self.headless = headless
self.session = None
self._crawler = None
# 站点级别容错跟踪
self.site_failures: dict[str, int] = {}
# 最近一次抓取的原始 HTML(供翻页链接发现使用)
self._last_raw_html: str | None = None
def _get_session(self) -> httpx.Client:
if self.session is None:
self.session = httpx.Client(
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/125.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Referer": "https://www.baidu.com/",
},
timeout=30,
follow_redirects=True,
)
return self.session
async def _get_crawler(self):
"""获取或创建 crawl4ai 实例"""
if HAS_CRAWL4AI and self._crawler is None:
browser_cfg = BrowserConfig(headless=self.headless, verbose=False)
self._crawler = AsyncWebCrawler(config=browser_cfg)
await self._crawler.__aenter__()
if not self.headless:
print(" [*] 浏览器窗口已打开(crawl4ai 控制)")
return self._crawler
async def close(self):
if self._crawler:
await self._crawler.__aexit__(None, None, None)
self._crawler = None
if self.session:
self.session.close()
self.session = None
# ============================================================
# 页面抓取(带重试)
# ============================================================
async def crawl_page_with_retry(self, url: str, max_retries: int = 2, timeout: int = 30000) -> tuple[str, bool]:
"""抓取页面,返回 (html内容, 是否成功),失败自动重试"""
for attempt in range(max_retries + 1):
print(f" [~] 正在获取页面... (尝试 {attempt+1}/{max_retries+1})")
try:
html = await self._crawl_one(url, timeout)
if html:
print(f" [~] 页面获取成功,内容长度: {len(html)} 字符")
return html, True
print(f" [!] 页面内容为空")
except Exception as e:
print(f" [!] 抓取失败 (尝试 {attempt+1}/{max_retries+1}): {url[:60]} - {e}")
if attempt < max_retries:
wait = 3 * (attempt + 1)
print(f" [~] 等待 {wait} 秒后重试...")
await asyncio.sleep(wait)
return "", False
async def _crawl_one(self, url: str, timeout: int) -> str:
"""单次页面抓取,返回纯文本内容(同时保存原始 HTML 供翻页发现)"""
if HAS_CRAWL4AI:
# crawl4ai 统一处理 headless / visible 两种模式
crawler = await self._get_crawler()
result = await crawler.arun(url, config=CrawlerRunConfig(verbose=False))
if result and result.success:
# fit_html 是 AI 清洗后的正文(翻页链接常被清洗掉)
# cleaned_html 保留完整结构(用于翻页发现)
self._last_raw_html = result.cleaned_html or result.html or ""
html = result.fit_html or result.cleaned_html or ""
return self._html_to_text(html)
return ""
else:
# 无 crawl4ai 时 fallback 到 requests
resp = self._get_session().get(url, timeout=timeout / 1000)
if resp.status_code == 200:
self._last_raw_html = resp.text
return self._html_to_text(resp.text)
return ""
@staticmethod
def _html_to_text(html: str) -> str:
"""简易 HTML 转纯文本"""
# 移除 script/style 标签内容
text = re.sub(r'<script[^>]*>.*?</script>', '', html, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r'<style[^>]*>.*?</style>', '', text, flags=re.DOTALL | re.IGNORECASE)
# 移除 HTML 标签
text = re.sub(r'<[^>]+>', ' ', text)
# 合并空白
text = re.sub(r'\s+', ' ', text).strip()
return text
# ============================================================
# 搜索笑话站点
# ============================================================
# 已知的笑话聚合站域名黑/白名单
JOKE_SITE_KEYWORDS = [
"笑话大全", "冷笑话", "搞笑段子", "笑话集锦",
"幽默笑话", "爆笑笑话", "成人笑话", "小笑话",
]
async def search_joke_sites(self, keywords: list[str], max_results: int = 10) -> list[dict]:
"""搜索笑话站点,返回 [{url, title, domain}]"""
all_results = []
for keyword in keywords:
results = await self._search_and_filter(keyword, max_results=max_results)
all_results.extend(results)
# 去重(按域名)
seen_domains = set()
unique = []
for r in all_results:
domain = r["domain"]
if domain not in seen_domains:
seen_domains.add(domain)
unique.append(r)
print(f" [*] 搜索到 {len(unique)} 个唯一站点")
for s in unique:
print(f" - {s['domain']}: {s['title'][:40]}")
return unique[:max_results]
async def _search_and_filter(self, keyword: str, max_results: int = 10) -> list[dict]:
"""搜索并过滤出疑似笑话聚合站的结果"""
# 用多个搜索词提高覆盖率
search_queries = [
f"{keyword} 网站",
f"{keyword} 大全",
f"{keyword} 列表",
]
seen = set()
sites = []
for q in search_queries:
if len(sites) >= max_results:
break
search_results = await self._search_bing(q, max_pages=2)
for r in search_results:
if len(sites) >= max_results:
break
url = r["url"]
domain = urlparse(url).netloc.lower()
if domain in seen:
continue
seen.add(domain)
# 过滤:排除已知的单篇文章站点和搜索引擎
if self._is_joke_collection_site(url, r.get("title", "")):
r["domain"] = domain
sites.append(r)
return sites
def _is_joke_collection_site(self, url: str, title: str) -> bool:
"""判断URL是否疑似笑话聚合站(不是单篇文章)"""
domain = urlparse(url).netloc.lower()
path = urlparse(url).path.lower()
# 排除项
exclude_domains = [
"bing.com", "microsoft.com", "baidu.com", "google.com",
"sohu.com", "sina.com", "163.com", "qq.com", "toutiao.com",
"weibo.com", "zhihu.com", "bilibili.com", "douban.com",
]
if any(d in domain for d in exclude_domains):
return False
# 排除明显的单篇文章模式
single_article_patterns = [
r'/p/\d+', r'/article/\d+', r'/post/\d+', r'/archives/\d+',
r'/a/\d+', r'/\d{5,}', r'/detail/\d+', r'/read/\d+',
r'\.html$', # 静态 html 文章页
]
# 但如果域名本身含 joke 特征,不排除
is_joke_domain = any(kw in domain or kw in title for kw in
["joke", "xiaohua", "笑话", "段子", "幽默", "搞笑"])
for p in single_article_patterns:
if re.search(p, path) and not is_joke_domain:
return False
# 聚合站特征:域名或标题含特定词,或URL有分类/列表模式
collection_patterns = [
"joke", "xiaohua", "笑话", "段子", "幽默", "搞笑",
"/page/", "/list/", "/category/", "/tag/", "joke",
]
for p in collection_patterns:
if p in domain or p in path or p.lower() in title:
return True
# 有列表/目录模式的也认为是聚合站
if re.search(r'(page|list|category|tag|index)', path):
return True
return False
# ============================================================
# 翻页链接发现
# ============================================================
def discover_page_links(self, html: str, base_url: str) -> list[str]:
"""从页面 HTML 中发现翻页链接,返回去重排序后的 URL 列表"""
# 优先使用 _last_raw_html(完整 HTML 而非纯文本),兜底用传入的 html
raw = self._last_raw_html or html
base_parsed = urlparse(base_url)
base_domain = f"{base_parsed.scheme}://{base_parsed.netloc}"
links = set()
# 1. <link rel="next">
for m in re.finditer(r'<link[^>]*rel="next"[^>]*href="([^"]+)"', raw, re.IGNORECASE):
links.add(urljoin(base_domain, m.group(1)))
# 2. 翻页文字链接(下一页、下页、>、» 等)
page_text_patterns = [
r'<a[^>]*href="([^"]*page[^"]*)"[^>]*>\s*(?:下一页|下页|下一页»|»||>|Next|last)\s*</a>',
r'<a[^>]*>\s*(?:下一页|下页|»||>)\s*</a>\s*<a[^>]*href="([^"]*)"',
]
for pattern in page_text_patterns:
for m in re.finditer(pattern, raw, re.IGNORECASE):
href = m.group(1).strip()
if href and href not in ("#", "javascript:void(0)"):
links.add(urljoin(base_url, href))
# 3. 提取所有带数字的翻页 link?page=N, /page/N/, index_N.html, page_N.html, &page=N
page_link_patterns = [
r'href="([^"]*[?&]page=(\d+)[^"]*)"', # ?page=2 &page=2
r'href="([^"]*/page/(\d+)[^"]*)"', # /page/2/
r'href="([^"]*[?&]p=(\d+)[^"]*)"', # ?p=2
r'href="([^"]*[?&]pn=(\d+)[^"]*)"', # ?pn=2
r'href="([^"]*[?&]offset=(\d+)[^"]*)"', # ?offset=10
r'href="([^"]*[?&]start=(\d+)[^"]*)"', # ?start=10
r'href="([^"]*[?&]page_index=(\d+)[^"]*)"', # ?page_index=2
# 匹配 xxx_N.html / page_N.html / list_2.html
r'href="([^"]*(?:page|list|index)[-_]?(\d+)\.html?)"',
# 匹配 /page_N/ 格式
r'href="([^"]*/page[-_]?(\d+)/?)"',
]
for pattern in page_link_patterns:
for m in re.finditer(pattern, raw, re.IGNORECASE):
full_url = urljoin(base_url, m.group(1))
links.add(full_url)
# 4. 翻页数字链接
for m in re.finditer(r'<a[^>]*href="([^"]*page=(\d+)[^"]*)"[^>]*>\s*\d+\s*</a>', raw, re.IGNORECASE):
links.add(urljoin(base_url, m.group(1)))
# 过滤:只保留同一域名下的链接
result = []
for link in links:
parsed = urlparse(link)
if parsed.netloc and parsed.netloc != base_parsed.netloc:
continue # 跨域排除
if parsed.path == base_parsed.path and parsed.query == base_parsed.query:
continue # 排除自身
result.append(link)
# 去重排序
return sorted(set(result))
def _extract_page_number(self, url: str) -> int:
"""从 URL 中提取页码,用于排序"""
nums = re.findall(r'page[=/](\d+)|[?&]p=(\d+)|index[-_]?(\d+)|/page[-_]?(\d+)', url, re.IGNORECASE)
for n in nums:
for g in n:
if g:
return int(g)
return 99 # 没识别到页码的排最后
# ============================================================
# 分类/标签链接发现
# ============================================================
def discover_category_links(self, html: str, base_url: str) -> list[str]:
"""从页面 HTML 中发现分类/标签链接,返回去重排序后的 URL 列表"""
raw = self._last_raw_html or html
base_parsed = urlparse(base_url)
base_domain = f"{base_parsed.scheme}://{base_parsed.netloc}"
links = set()
# 1. 匹配分类链接(category-N.html, category-N_M.html, tag-N.html 等)
cat_patterns = [
r'href="([^"]*/(?:category|cat|sort|type)[-_]?\d+(?:[-_]\d+)?\.html?)"',
r'href="([^"]*/(?:tag|tags)/?[-_]?\d*)["\s>]',
r'href="([^"]*/tags?[-_]?\d+\.html?)"',
]
for pattern in cat_patterns:
for m in re.finditer(pattern, raw, re.IGNORECASE):
full_url = urljoin(base_domain, m.group(1))
parsed = urlparse(full_url)
# 只保留同域链接
if parsed.netloc and parsed.netloc != base_parsed.netloc:
continue
links.add(full_url)
# 2. 过滤:排除单篇文章、首页、搜索页
single_article = re.compile(
r'/(?:p|post|article|archives|detail|read|xiaohua)/\d+',
re.IGNORECASE,
)
result = []
for link in links:
parsed = urlparse(link)
path = parsed.path.rstrip("/")
# 排除自身
if path == base_parsed.path.rstrip("/") and parsed.query == base_parsed.query:
continue
# 排除单篇文章
if single_article.search(path):
continue
# 排除明显的非分类路径(首页翻页)
if re.search(r'/page[-_]?\d+\.html?$', path) and 'category' not in path and 'tag' not in path:
continue
result.append(link)
return sorted(set(result))
# ============================================================
# Bing 搜索(复用旧逻辑)
# ============================================================
async def _search_bing(self, keyword: str, max_pages: int = 3) -> list[dict]:
"""搜索 Bing,返回结果 URL 列表"""
results = []
session = self._get_session()
for page in range(max_pages):
first = page * 10
url = f"https://www.bing.com/search?q={quote(keyword)}&first={first}"
print(f" [*] Bing 搜索: {keyword[:20]}")
html = None
try:
if HAS_CRAWL4AI:
crawler = await self._get_crawler()
result = await crawler.arun(url, config=CrawlerRunConfig(verbose=False))
if result.success:
html = result.html if result.html else result.cleaned_html
else:
resp = session.get(url)
html = resp.text if resp.status_code == 200 else None
if html:
urls = self._extract_bing_urls(html)
for item in urls:
item["keyword"] = keyword
results.append(item)
else:
print(f" [!] 获取搜索页面失败")
await asyncio.sleep(2)
except Exception as e:
print(f" [!] 搜索异常: {e}")
continue
return results
def _extract_bing_urls(self, html: str) -> list[dict]:
"""从 Bing 搜索结果 HTML 中提取链接和标题"""
results = []
pattern = re.compile(
r'<h2[^>]*>\s*<a[^>]*href="(https?[^"]+)"[^>]*>(.*?)</a>',
re.DOTALL,
)
for match in pattern.finditer(html):
href = match.group(1).strip()
title = re.sub(r'<[^>]+>', '', match.group(2)).strip()
if href and title and len(title) > 5 and "bing.com" not in href and "microsoft.com" not in href:
results.append({"url": href, "title": title})
return results
# 别名兼容
async def search_bing(self, keyword: str, max_pages: int = 3) -> list[dict]:
return await self._search_bing(keyword, max_pages)
async def search(self, keyword: str, max_pages: int = 3) -> list[dict]:
return await self._search_bing(keyword, max_pages)
async def search_baidu(self, keyword: str, max_pages: int = 3) -> list[dict]:
return await self._search_bing(keyword, max_pages)
# ============================================================
# 旧接口兼容(单页抓取)
# ============================================================
async def crawl_page(self, url: str) -> str:
"""抓取单个页面(兼容旧接口)"""
content, ok = await self.crawl_page_with_retry(url)
if ok:
return content[:8000]
return ""
async def crawl_batch(self, urls: list[str]) -> list[tuple[str, str]]:
"""批量抓取(兼容旧接口)"""
contents = []
for url in urls:
content, ok = await self.crawl_page_with_retry(url)
contents.append((url, content[:8000] if ok else ""))
await asyncio.sleep(1.5)
return contents
+85
View File
@@ -0,0 +1,85 @@
"""
爬虫持续采集入口(深度采集模式)。
流程:搜索笑话站点 → 翻页深度采集 → AI 提取 → 入库。
每批采集 20 条后休息 2-10 分钟,出错休息 3-11 分钟后继续。
按 Ctrl+C 中断。
用法:
python crawler/main.py # 默认,无头浏览器
python crawler/main.py --no-headless # 显示浏览器窗口(方便测试)
"""
import argparse
import asyncio
import os
import sys
if sys.platform == "win32":
import shutil
os.environ["PYTHONIOENCODING"] = "utf-8"
os.environ["TERM"] = "dumb"
try:
shutil.get_terminal_size()
except Exception:
pass
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from crawler.processor import Processor
def parse_args():
parser = argparse.ArgumentParser(description="笑话爬虫 - 深度采集模式")
parser.add_argument(
"--no-headless",
action="store_true",
help="显示浏览器窗口(默认无头模式)",
)
return parser.parse_args()
def main():
args = parse_args()
headless = not args.no_headless
api_base = os.getenv("API_BASE", "http://localhost:8001")
username = os.getenv("CRAWL_USERNAME", "admin")
password = os.getenv("CRAWL_PASSWORD", "admin123")
keyword_str = os.getenv("CRAWL_KEYWORD", "笑话大全,搞笑段子,冷笑话,幽默笑话,爆笑笑话,笑话集锦")
keywords = [k.strip() for k in keyword_str.split(",") if k.strip()]
if not keywords:
print("错误: 未设置关键词")
return
print(f"=" * 50)
print(f"笑话爬虫 - 深度采集模式")
print(f"搜索词: {keywords}")
print(f"API 地址: {api_base}")
print(f"浏览器: {'显示窗口' if not headless else '无头模式'}")
print(f"每批 20 条后休息 2-10 分钟")
print(f"出错后休息 3-11 分钟后重试")
print(f"按 Ctrl+C 终止")
print(f"=" * 50)
try:
import crawl4ai
print(f"crawl4ai 版本: {crawl4ai.__version__}")
except ImportError:
print("错误: crawl4ai 未安装,请先运行: pip install crawl4ai")
return
processor = Processor(
api_base=api_base,
username=username,
password=password,
headless=headless,
)
try:
asyncio.run(processor.run_continuous(keywords=keywords, max_pages=3, batch_size=20))
except KeyboardInterrupt:
print("\n用户中断,退出")
if __name__ == "__main__":
main()
+470
View File
@@ -0,0 +1,470 @@
"""爬虫流程编排:搜索笑话站点 → 深度翻页采集 → AI 提取 → 入库"""
import asyncio
import hashlib
import random
import re
import httpx
from urllib.parse import urljoin, urlparse
from crawler.ai_service import AiService
from crawler.crawler_service import CrawlerService
class Processor:
def __init__(self, api_base: str, username: str, password: str, headless: bool = True):
self.api_base = api_base.rstrip("/")
self.username = username
self.password = password
self.token = None
self.ai = None
self.crawler = CrawlerService(headless=headless)
self.types = []
self.crowds = []
# === API 认证 ===
def _login(self) -> str:
resp = httpx.post(
f"{self.api_base}/api/auth/login",
json={"username": self.username, "password": self.password},
timeout=30,
)
resp.raise_for_status()
return resp.json()["access_token"]
def _get(self, path: str) -> dict | list:
resp = httpx.get(
f"{self.api_base}{path}",
headers={"Authorization": f"Bearer {self.token}"},
timeout=30,
)
resp.raise_for_status()
return resp.json()
def _post(self, path: str, data: dict) -> dict:
resp = httpx.post(
f"{self.api_base}{path}",
json=data,
headers={"Authorization": f"Bearer {self.token}"},
timeout=30,
)
resp.raise_for_status()
return resp.json()
# === 初始化 ===
def setup(self):
print("[*] 正在登录...")
self.token = self._login()
print("[*] 登录成功")
ai_config = self._get("/api/admin/settings/active")
self.ai = AiService(
api_base=ai_config["api_base"],
api_key=ai_config["api_key"],
model_name=ai_config["model_name"],
temperature=ai_config.get("temperature", 0.7),
max_tokens=ai_config.get("max_tokens", 2048),
)
print(f"[*] AI 配置: {ai_config['model_name']}")
self.types = self._get("/api/categories/types")
self.crowds = self._get("/api/categories/crowds")
print(f"[*] 分类: {len(self.types)} 种类型, {len(self.crowds)} 种人群")
def _get_existing_hashes(self) -> set[str]:
"""获取库里已有笑话的 content hash,用于去重"""
try:
data = self._get("/api/admin/jokes?page=1&page_size=1000")
hashes = set()
for j in data.get("items", []):
content = j.get("content", "")
if content:
hashes.add(hashlib.md5(content.encode()).hexdigest())
return hashes
except Exception:
return set()
def _submit_joke(self, joke: dict) -> bool:
"""提交单条笑话至 APIstatus=pending 待审核)"""
try:
# Support both old format (type/crowd) and new format (types/crowds arrays)
type_names = joke.get("types", [joke.get("type", "")])
crowd_names = joke.get("crowds", [joke.get("crowd", "")])
if isinstance(type_names, str):
type_names = [type_names] if type_names else []
if isinstance(crowd_names, str):
crowd_names = [crowd_names] if crowd_names else []
type_ids = []
crowd_ids = []
for n in type_names:
for t in self.types:
if t.get("name") == n:
type_ids.append(t.get("id"))
break
for n in crowd_names:
for c in self.crowds:
if c.get("name") == n:
crowd_ids.append(c.get("id"))
break
payload = {
"title": joke.get("title", "无标题"),
"content": joke.get("content", ""),
"type_ids": type_ids if type_ids else None,
"crowd_ids": crowd_ids if crowd_ids else None,
"status": "pending",
}
self._post("/api/admin/jokes", payload)
return True
except Exception as e:
print(f" [!] 提交失败: {e}")
return False
# === 主循环 ===
async def run_continuous(self, keywords: list[str], max_pages: int = 3, batch_size: int = 20):
"""持续深度采集循环"""
print(f"\n>> 深度采集模式启动")
print(f" 搜索词: {keywords}")
print(f" 每批目标: {batch_size}")
print(f" API: {self.api_base}")
print(f" 按 Ctrl+C 中断\n")
self.setup()
existing_hashes = self._get_existing_hashes()
print(f"[*] 当前库中已有 {len(existing_hashes)} 条笑话(用于去重)")
type_names = [t.get("name", "") for t in self.types]
crowd_names = [c.get("name", "") for c in self.crowds]
total_saved = 0
visited_urls: set[str] = set()
try:
while True:
batch_saved = 0
print(f"\n{'='*50}")
print(f" 开始新一批深度采集 (已累计 {total_saved} 条)")
print(f"{'='*50}")
try:
# Step 1: 搜索笑话站点
print(f"\n[*] 搜索笑话站点...")
sites = await self.crawler.search_joke_sites(keywords, max_results=8)
if not sites:
print(f" [!] 未找到笑话站点,休息后重试")
rest = random.randint(120, 600)
print(f" 休息 {rest//60}{rest%60} 秒...")
await asyncio.sleep(rest)
continue
# Step 2: 逐个站点深度采集
for site in sites:
if batch_saved >= batch_size:
break
domain = site["domain"]
site_url = site["url"]
print(f"\n{''*40}")
print(f" 开始采集站点: {domain}")
print(f"{''*40}")
# 检查该站点失败次数
fail_count = self.crawler.site_failures.get(domain, 0)
if fail_count >= 3:
print(f" [!] 站点 {domain} 已连续失败 {fail_count} 次,跳过")
continue
saved_from_site = await self._crawl_site_deep(
site_url=site_url,
domain=domain,
type_names=type_names,
crowd_names=crowd_names,
existing_hashes=existing_hashes,
visited_urls=visited_urls,
target=batch_size - batch_saved,
)
batch_saved += saved_from_site
total_saved += saved_from_site
# Step 3: 休息
rest = random.randint(120, 600)
print(f"\n[OK] 本批入库 {batch_saved} 条,休息 {rest//60}{rest%60} 秒...")
print(f" 按 Ctrl+C 中断\n")
except Exception as e:
rest = random.randint(180, 660)
print(f"\n[!] 出错: {e}")
print(f" 休息 {rest//60}{rest%60} 秒后重试...")
await asyncio.sleep(rest)
except asyncio.CancelledError:
print("\n用户中断,退出")
finally:
await self.crawler.close()
async def _crawl_site_deep(
self,
site_url: str,
domain: str,
type_names: list[str],
crowd_names: list[str],
existing_hashes: set[str],
visited_urls: set[str],
target: int,
) -> int:
"""深度采集一个站点:抓取首页 → 发现翻页链接 → 逐页抓取提取笑话"""
saved = 0
pages_to_crawl = []
# 1. 抓取首页
print(f" [*] 抓取首页: {site_url[:60]}")
html, ok = await self.crawler.crawl_page_with_retry(site_url)
if not ok:
self.crawler.site_failures[domain] = self.crawler.site_failures.get(domain, 0) + 1
print(f" [!] 首页抓取失败 ({self.crawler.site_failures[domain]}/3)")
return 0
visited_urls.add(site_url)
# 2. 从首页提取笑话
try:
jokes = self.ai.extract_jokes(html[:8000], type_names, crowd_names)
saved += self._save_jokes(jokes, existing_hashes, target - saved)
print(f" [+] 首页提取 {len(jokes)} 条,入库 {saved}")
except Exception as e:
print(f" [!] 首页 AI 提取失败: {e}")
if saved >= target:
self.crawler.site_failures[domain] = 0
return saved
# 3. 发现翻页链接
page_links = self.crawler.discover_page_links(html, site_url)
# 过滤已访问的链接
page_links = [l for l in page_links if l not in visited_urls]
# 按页码排序
page_links.sort(key=lambda l: self.crawler._extract_page_number(l))
# 限制翻页深度,避免无限抓取
max_pages_per_site = 30
page_links = page_links[:max_pages_per_site]
print(f" [*] 发现 {len(page_links)} 个翻页链接,开始逐页采集...")
# 4. 逐页翻页采集
for idx, page_url in enumerate(page_links):
if saved >= target:
break
# 检查站点是否已失效
if self.crawler.site_failures.get(domain, 0) >= 3:
print(f" [!] 站点 {domain} 失败过多,跳过")
break
print(f" [*] 翻页 {idx+1}/{len(page_links)}: {page_url[:60]}")
html, ok = await self.crawler.crawl_page_with_retry(page_url)
visited_urls.add(page_url)
if not ok:
self.crawler.site_failures[domain] = self.crawler.site_failures.get(domain, 0) + 1
print(f" [!] 抓取失败 ({self.crawler.site_failures[domain]}/3)")
continue
# 重置失败计数
self.crawler.site_failures[domain] = 0
try:
jokes = self.ai.extract_jokes(html[:8000], type_names, crowd_names)
new_saved = self._save_jokes(jokes, existing_hashes, target - saved)
if new_saved > 0:
saved += new_saved
print(f" [+] 提取 {len(jokes)} 条,入库 {new_saved} 条 (累计 {saved}/{target})")
else:
print(f" [*] 提取 {len(jokes)} 条(均为重复)")
except Exception as e:
print(f" [!] AI 提取失败: {e}")
await asyncio.sleep(random.uniform(1, 3))
self.crawler.site_failures[domain] = 0
# 5. 发现分类链接并逐个深度采集
cat_links = self.crawler.discover_category_links(html, site_url)
cat_links = [l for l in cat_links if l not in visited_urls]
# 限制分类数量
max_categories = 20
cat_links = cat_links[:max_categories]
if cat_links:
print(f" [*] 发现 {len(cat_links)} 个分类链接,开始逐类采集...")
for cat_url in cat_links:
if saved >= target:
break
if self.crawler.site_failures.get(domain, 0) >= 3:
print(f" [!] 站点 {domain} 失败过多,跳过分类")
break
saved += await self._crawl_category(
cat_url=cat_url,
domain=domain,
type_names=type_names,
crowd_names=crowd_names,
existing_hashes=existing_hashes,
visited_urls=visited_urls,
target=target - saved,
)
print(f" [*] 站点 {domain} 采集完成,共入库 {saved}")
return saved
async def _crawl_category(
self,
cat_url: str,
domain: str,
type_names: list[str],
crowd_names: list[str],
existing_hashes: set[str],
visited_urls: set[str],
target: int,
) -> int:
"""深度采集一个分类页及其翻页"""
saved = 0
cat_name = cat_url.split("/")[-1].split(".")[0]
print(f"\n {''*36}")
print(f" 分类采集 [{cat_name}]: {cat_url[:60]}")
print(f" {''*36}")
# 1. 抓取分类首页
html, ok = await self.crawler.crawl_page_with_retry(cat_url)
visited_urls.add(cat_url)
if not ok:
self.crawler.site_failures[domain] = self.crawler.site_failures.get(domain, 0) + 1
print(f" [!] 分类首页抓取失败 ({self.crawler.site_failures[domain]}/3)")
return 0
# 2. AI 提取笑话
try:
jokes = self.ai.extract_jokes(html[:8000], type_names, crowd_names)
saved += self._save_jokes(jokes, existing_hashes, target - saved)
print(f" [+] 分类首页提取 {len(jokes)} 条,入库 {saved}")
except Exception as e:
print(f" [!] 分类首页 AI 提取失败: {e}")
if saved >= target:
return saved
# 3. 发现该分类的翻页链接
# 先尝试通用翻页模式,再尝试分类特定翻页(category-5_2.html
page_links = self.crawler.discover_page_links(html, cat_url)
# 从当前分类 URL 派生出分类翻页模式(e.g. category-5 → category-5_2.html
cat_base = cat_url.split("/")[-1].replace(".html", "")
cat_page_pattern = re.compile(
rf'href="([^"]*{re.escape(cat_base)}[-_]?(\d+)\.html?)"',
re.IGNORECASE,
)
raw = self.crawler._last_raw_html or html
for m in cat_page_pattern.finditer(raw):
full_url = urljoin(cat_url, m.group(1))
page_links.append(full_url)
page_links = [l for l in page_links if l not in visited_urls]
page_links = list(set(page_links)) # 去重
page_links.sort(key=lambda l: self.crawler._extract_page_number(l))
# 限制翻页深度
max_pages_per_cat = 20
page_links = page_links[:max_pages_per_cat]
if page_links:
print(f" [*] 发现 {len(page_links)} 个翻页链接,开始逐页采集...")
# 4. 逐页抓取
for idx, page_url in enumerate(page_links):
if saved >= target:
break
if self.crawler.site_failures.get(domain, 0) >= 3:
print(f" [!] 站点 {domain} 失败过多,跳过本分类")
break
print(f" [*] 翻页 {idx+1}/{len(page_links)}: {page_url[:60]}")
html, ok = await self.crawler.crawl_page_with_retry(page_url)
visited_urls.add(page_url)
if not ok:
self.crawler.site_failures[domain] = self.crawler.site_failures.get(domain, 0) + 1
print(f" [!] 分类翻页抓取失败 ({self.crawler.site_failures[domain]}/3)")
continue
self.crawler.site_failures[domain] = 0
try:
jokes = self.ai.extract_jokes(html[:8000], type_names, crowd_names)
new_saved = self._save_jokes(jokes, existing_hashes, target - saved)
if new_saved > 0:
saved += new_saved
print(f" [+] 提取 {len(jokes)} 条,入库 {new_saved} 条 (累计 {saved}/{target})")
else:
print(f" [*] 提取 {len(jokes)} 条(均为重复)")
except Exception as e:
print(f" [!] AI 提取失败: {e}")
await asyncio.sleep(random.uniform(1, 3))
print(f" [*] 分类 [{cat_name}] 采集完成,入库 {saved}")
return saved
def _save_jokes(self, jokes: list[dict], existing_hashes: set[str], limit: int) -> int:
"""去重并入库笑话,返回成功入库数"""
saved = 0
for joke in jokes:
if saved >= limit:
break
content_text = joke.get("content", "")
if not content_text:
continue
h = hashlib.md5(content_text.encode()).hexdigest()
if h in existing_hashes:
continue
existing_hashes.add(h)
if self._submit_joke(joke):
saved += 1
print(f" [+] 入库: {joke.get('title', '')[:30]}")
return saved
# === 单站点采集(供 site_crawler.py 调用) ===
async def crawl_site(self, site_url: str, batch_size: int = 9999):
"""初始化后深度采集单个站点"""
print(f"\n>> 单站点采集: {site_url}\n")
self.setup()
existing_hashes = self._get_existing_hashes()
print(f"[*] 当前库中已有 {len(existing_hashes)} 条笑话(用于去重)")
type_names = [t.get("name", "") for t in self.types]
crowd_names = [c.get("name", "") for c in self.crowds]
domain = urlparse(site_url).netloc.lower()
visited_urls: set[str] = set()
try:
saved = await self._crawl_site_deep(
site_url=site_url,
domain=domain,
type_names=type_names,
crowd_names=crowd_names,
existing_hashes=existing_hashes,
visited_urls=visited_urls,
target=batch_size,
)
print(f"\n[OK] 站点采集完成,共入库 {saved} 条笑话")
finally:
await self.crawler.close()
# === 旧接口兼容 ===
async def run(self, keywords: list[str], max_pages: int = 3):
"""单轮爬取(旧接口,内部调用 run_continuous"""
await self.run_continuous(keywords, max_pages, batch_size=9999)
+41
View File
@@ -0,0 +1,41 @@
"""AI 提示词模板。"""
# ===== 笑话提取 =====
EXTRACTION_SYSTEM_PROMPT = """你是一个笑话提取专家。从给定的网页文本中识别并提取所有笑话、幽默段子或有趣内容。
要求:
1. 只返回真正的笑话内容,不要提取普通文章或新闻
2. 每条笑话需要包含:title(简短标题)、content(完整笑话内容)、type(类型)、crowd(人群)
3. 如果网页中没有笑话,返回空数组 []
4. 永远返回合法的 JSON 格式,根节点为数组或包含 jokes 键的对象"""
EXTRACTION_USER_PROMPT = """网页内容:
---
{page_content}
---
已知笑话类型:{known_types}
已知人群分类:{known_crowds}
请提取所有笑话,以 JSON 格式返回,示例:
[
{{"title": "程序员的幽默", "content": "程序员去相亲...", "types": ["谐音梗", "段子"], "crowds": ["职场", "大学生"]}},
{{"title": "...", "content": "...", "types": ["..."], "crowds": ["..."]}}
]
注意:types 和 crowds 是数组,可以填多个。
只返回 JSON,不要其他文字。"""
# ===== 笑话改写 =====
REWRITE_SYSTEM_PROMPT = """你是一个幽默作家,负责润色和改写笑话。
要求:
1. 保持笑话的核心笑点不变
2. 语言更通顺、更幽默
3. 字数控制在原内容的 80%-120% 之间
4. 不要添加任何解释说明"""
REWRITE_USER_PROMPT = """请润色以下笑话:
{content}
只返回润色后的笑话文字,不要其他内容。"""
+165
View File
@@ -0,0 +1,165 @@
"""
单站点深度采集工具。
从 JSON 文件按编号加载站点,或直接指定 URL,深度采集该站点所有笑话。
用法:
python crawler/site_crawler.py --id 1 # 采集 site_finder 发现的 #1 站点
python crawler/site_crawler.py --id 1,2,3 # 批量采集多个站点
python crawler/site_crawler.py --url https://... # 直接采集指定 URL
python crawler/site_crawler.py --id 1 --no-headless # 显示浏览器窗口
"""
import argparse
import asyncio
import json
import os
import sys
if sys.platform == "win32":
import shutil
os.environ["PYTHONIOENCODING"] = "utf-8"
os.environ["TERM"] = "dumb"
try:
shutil.get_terminal_size()
except Exception:
pass
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from crawler.processor import Processor
def parse_args():
parser = argparse.ArgumentParser(description="单站点深度采集工具")
parser.add_argument(
"--id",
type=str,
default=None,
help="站点编号(从 site_finder 生成的 JSON 读取),多个用逗号分隔",
)
parser.add_argument(
"--url",
type=str,
default=None,
help="直接指定站点 URL(与 --id 二选一)",
)
parser.add_argument(
"--sites-file",
type=str,
default="joke_sites.json",
help="站点列表 JSON 文件路径(默认: joke_sites.json",
)
parser.add_argument(
"--api-base",
type=str,
default=os.getenv("API_BASE", "http://localhost:8001"),
help="API 服务地址",
)
parser.add_argument(
"--username",
type=str,
default=os.getenv("CRAWL_USERNAME", "admin"),
help="管理员用户名",
)
parser.add_argument(
"--password",
type=str,
default=os.getenv("CRAWL_PASSWORD", "admin123"),
help="管理员密码",
)
parser.add_argument(
"--no-headless",
action="store_true",
help="显示浏览器窗口",
)
return parser.parse_args()
def load_sites(path: str) -> list[dict]:
"""从 JSON 文件加载站点列表"""
if not os.path.exists(path):
print(f"错误: 站点文件 {path} 不存在,请先运行 site_finder.py")
sys.exit(1)
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if not data:
print(f"错误: 站点文件 {path} 为空")
sys.exit(1)
return data
except Exception as e:
print(f"错误: 读取站点文件失败: {e}")
sys.exit(1)
def resolve_sites(args) -> list[str]:
"""解析 --id 或 --url 参数,返回待采集的 URL 列表"""
if args.url:
return [args.url]
if not args.id:
print("错误: 请指定 --id 或 --url")
print(" 例如: python crawler/site_crawler.py --id 1")
print(" 例如: python crawler/site_crawler.py --url https://xiaohua.com")
sys.exit(1)
# 解析编号列表 "1,2,3" → [1, 2, 3]
try:
ids = [int(x.strip()) for x in args.id.split(",") if x.strip()]
except ValueError:
print("错误: --id 参数必须是数字,多个用逗号分隔")
sys.exit(1)
sites = load_sites(args.sites_file)
found = []
for sid in ids:
match = [s for s in sites if s["id"] == sid]
if match:
found.append(match[0])
print(f" [*] 站点 #{sid}: {match[0]['domain']}{match[0]['title'][:40]}")
else:
print(f" [!] 站点 #{sid} 未找到(可用编号: {[s['id'] for s in sites[:10]]}...)")
if not found:
print("错误: 没有找到有效的站点编号")
sys.exit(1)
return [s["url"] for s in found]
def main():
args = parse_args()
headless = not args.no_headless
urls = resolve_sites(args)
print(f"=" * 50)
print(f"单站点深度采集工具")
print(f"目标站点: {len(urls)}")
for u in urls:
print(f" - {u}")
print(f"API 地址: {args.api_base}")
print(f"浏览器: {'显示窗口' if not headless else '无头模式'}")
print(f"=" * 50)
try:
import crawl4ai
print(f"crawl4ai 版本: {crawl4ai.__version__}")
except ImportError:
print("错误: crawl4ai 未安装,请先运行: pip install crawl4ai")
return
processor = Processor(
api_base=args.api_base,
username=args.username,
password=args.password,
headless=headless,
)
try:
for url in urls:
asyncio.run(processor.crawl_site(url))
except KeyboardInterrupt:
print("\n用户中断,退出")
if __name__ == "__main__":
main()
+128
View File
@@ -0,0 +1,128 @@
"""
笑话站点发现工具。
搜索 Bing 获取笑话聚合网站列表,保存到 JSON 文件。
用法:
python crawler/site_finder.py
python crawler/site_finder.py --keywords "笑话大全,冷笑话" --output my_sites.json
python crawler/site_finder.py --no-headless
"""
import argparse
import asyncio
import json
import os
import sys
from datetime import datetime
if sys.platform == "win32":
import shutil
os.environ["PYTHONIOENCODING"] = "utf-8"
os.environ["TERM"] = "dumb"
try:
shutil.get_terminal_size()
except Exception:
pass
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from crawler.crawler_service import CrawlerService
def parse_args():
parser = argparse.ArgumentParser(description="笑话站点发现工具")
parser.add_argument(
"--keywords",
type=str,
default=os.getenv("CRAWL_KEYWORD", "笑话大全,搞笑段子,冷笑话,幽默笑话,爆笑笑话,笑话集锦"),
help="搜索关键词,逗号分隔",
)
parser.add_argument(
"--output",
type=str,
default="joke_sites.json",
help="输出 JSON 文件路径(默认: joke_sites.json",
)
parser.add_argument(
"--max-sites",
type=int,
default=15,
help="最多保留几个站点(默认: 15",
)
parser.add_argument(
"--no-headless",
action="store_true",
help="显示浏览器窗口",
)
return parser.parse_args()
def load_existing_sites(path: str) -> list[dict]:
"""加载已有站点列表"""
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return []
def save_sites(path: str, sites: list[dict]):
"""保存站点列表到 JSON 文件"""
with open(path, "w", encoding="utf-8") as f:
json.dump(sites, f, ensure_ascii=False, indent=2)
print(f"\n[OK] 已保存 {len(sites)} 个站点到 {path}")
def main():
args = parse_args()
headless = not args.no_headless
keywords = [k.strip() for k in args.keywords.split(",") if k.strip()]
print(f"=" * 50)
print(f"笑话站点发现工具")
print(f"搜索词: {keywords}")
print(f"最大站点数: {args.max_sites}")
print(f"输出文件: {args.output}")
print(f"浏览器: {'显示窗口' if not headless else '无头模式'}")
print(f"=" * 50)
# 加载已存在的站点(保留已有编号)
existing = load_existing_sites(args.output)
existing_domains = {s["domain"] for s in existing}
next_id = max([s["id"] for s in existing], default=0) + 1
print(f"[*] 已有 {len(existing)} 个站点记录,新编号从 {next_id} 开始")
async def run():
nonlocal next_id
crawler = CrawlerService(headless=headless)
try:
new_sites = await crawler.search_joke_sites(keywords, max_results=args.max_sites)
finally:
await crawler.close()
# 合并新旧站点(去重)
added = 0
for site in new_sites:
domain = site["domain"]
if domain not in existing_domains:
site["id"] = next_id
site["found_at"] = datetime.now().isoformat(timespec="seconds")
existing.append(site)
existing_domains.add(domain)
next_id += 1
added += 1
print(f" [+] 新增 #{site['id']}: {site['domain']}{site['title'][:40]}")
save_sites(args.output, existing)
if existing:
print(f"\n站点列表:")
for s in existing:
print(f" #{s['id']:2d} {s['domain']:30s} {s['title'][:35]}")
asyncio.run(run())
if __name__ == "__main__":
main()
@@ -0,0 +1,754 @@
# 智能笑话生成器实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 实现用户通过选择场景和输入关键词,调用 AI 生成笑话的功能,支持收藏到本地。
**Architecture:** 后端新增 `/api/generate` 接口调用 AI 服务;前端新增 `/generate` 页面,复用现有笑话卡片样式,支持 localStorage 收藏。
**Tech Stack:** FastAPI + OpenAI SDK (NVIDIA NIM) / Vue3 + Element Plus + localStorage
---
## 文件变更概览
### 新增文件
- `api/app/routers/generate.py` - 生成器 API 路由
- `api/app/schemas/joke.py` - 添加 GenerateRequest/GenerateResponse schema
- `web/src/views/generate/index.vue` - 生成器页面
- `web/src/api/generate.js` - 前端 API 模块
### 修改文件
- `api/main.py` - 注册新路由
- `web/src/router/index.js` - 添加 /generate 路由
- `web/src/components/AppHeader.vue` - 导航栏添加 AI 生成入口按钮
---
## Task 1: 后端 Schema 定义
**Files:**
- Modify: `api/app/schemas/joke.py:1-45`
- [ ] **Step 1: 添加 GenerateRequest 和 GenerateResponse schema**
`api/app/schemas/joke.py` 文件末尾添加:
```python
class GenerateRequest(BaseModel):
"""笑话生成请求"""
keywords: list[str] = []
scenarios: list[str] = []
class Config:
json_schema_extra = {
"example": {
"keywords": ["加班"],
"scenarios": ["职场"]
}
}
class GenerateResponse(BaseModel):
"""笑话生成响应"""
title: str
content: str
created_at: datetime | None = None
class Config:
from_attributes = True
```
- [ ] **Step 2: 验证文件语法**
Run: `cd api && python -c "from app.schemas.joke import GenerateRequest, GenerateResponse; print('OK')"`
Expected: `OK`
- [ ] **Step 3: 提交**
```bash
git add api/app/schemas/joke.py
git commit -m "feat(api): add GenerateRequest/GenerateResponse schemas"
```
---
## Task 2: 后端 AI 生成路由
**Files:**
- Create: `api/app/routers/generate.py`
- Modify: `api/main.py:26-33`
- [ ] **Step 1: 创建生成器路由文件**
创建 `api/app/routers/generate.py`:
```python
"""智能笑话生成器 API"""
from datetime import datetime
from fastapi import APIRouter, HTTPException
from openai import OpenAI
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.setting import AiSetting
from app.schemas.joke import GenerateRequest, GenerateResponse
router = APIRouter(prefix="/generate", tags=["生成器"])
# AI Prompt
GENERATION_PROMPT = """你是一位幽默大师,专门创作轻松搞笑的短笑话。
{context}
要求:
1. 根据场景和关键词创作一条原创笑话
2. 笑话要有反转或意外结局
3. 语言简洁,30-150字
4. 直接输出笑话内容,不需要解释
格式:
标题:xxx
内容:xxx
"""
def _build_prompt(scenarios: list[str], keywords: list[str]) -> str:
"""构建 AI prompt"""
parts = []
if scenarios:
parts.append(f"场景:{', '.join(scenarios)}")
if keywords:
parts.append(f"关键词:{', '.join(keywords)}")
if not parts:
parts.append("场景:日常生活的各种趣事(不指定具体场景)")
return GENERATION_PROMPT.format(context="\n".join(parts))
@router.post("", response_model=GenerateResponse)
def generate_joke(
req: GenerateRequest,
db: Session = Depends(get_db),
):
"""调用 AI 生成笑话"""
# 获取激活的 AI 配置
setting = db.query(AiSetting).filter(AiSetting.is_active == True).first()
if not setting:
# 如果没有配置,尝试返回默认配置
setting = db.query(AiSetting).first()
if not setting or not setting.api_key:
raise HTTPException(status_code=503, detail="AI 服务未配置,请联系管理员")
# 调用 AI
try:
client = OpenAI(base_url=setting.api_base, api_key=setting.api_key)
prompt = _build_prompt(req.scenarios, req.keywords)
response = client.chat.completions.create(
model=setting.model_name,
messages=[{"role": "user", "content": prompt}],
temperature=setting.temperature,
max_tokens=setting.max_tokens,
)
raw = response.choices[0].message.content
return _parse_response(raw)
except Exception as e:
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
def _parse_response(raw: str) -> GenerateResponse:
"""解析 AI 返回内容,提取标题和内容"""
title = ""
content = raw
# 尝试提取 "标题:xxx" 或 "标题:xxx"
for line in raw.split("\n"):
line = line.strip()
if line.startswith("标题:") or line.startswith("标题:"):
title = line.split("", 1)[-1].split(":", 1)[-1].strip()
content = content.replace(line, "").strip()
break
# 如果没有提取到标题,取第一行或前20字
if not title:
first_line = raw.split("\n")[0].strip()
if first_line.startswith("标题"):
first_line = first_line.split("", 1)[-1].split(":", 1)[-1].strip()
title = first_line[:30] if len(first_line) > 30 else first_line
return GenerateResponse(
title=title or "生成的笑话",
content=content.strip(),
created_at=datetime.now(),
)
```
- [ ] **Step 2: 修改 main.py 注册路由**
`api/main.py` 第 5 行添加导入:
```python
from app.routers import jokes_router, categories_router, auth_router, admin_router, settings_router, links_router, feedback_router, generate_router
```
`api/main.py` 第 32 行后添加:
```python
app.include_router(generate_router, prefix="/api")
```
- [ ] **Step 3: 手动验证 API**
Run: `cd api && python -m uvicorn main:app --reload --port 8001`
Expected: 服务启动无错误
访问 `http://localhost:8001/docs` 验证 `/api/generate` 接口存在
- [ ] **Step 4: 测试无 AI 配置情况**
Run: `curl -X POST http://localhost:8001/api/generate -H "Content-Type: application/json" -d "{}"`
Expected: `{"detail":"AI 服务未配置,请联系管理员"}`
- [ ] **Step 5: 提交**
```bash
git add api/app/routers/generate.py api/main.py
git commit -m "feat(api): add joke generate endpoint with AI"
```
---
## Task 3: 前端 API 模块
**Files:**
- Create: `web/src/api/generate.js`
- [ ] **Step 1: 创建前端 API 模块**
创建 `web/src/api/generate.js`:
```javascript
import request from './request'
/**
* 调用 AI 生成笑话
* @param {string[]} keywords - 关键词列表
* @param {string[]} scenarios - 场景列表
* @returns {Promise<{title: string, content: string, created_at: string}>}
*/
export const generateJoke = (keywords = [], scenarios = []) => {
return request.post('/generate', {
keywords,
scenarios
})
}
```
- [ ] **Step 2: 验证语法**
Run: `cd web && node -c src/api/generate.js`
Expected: 无语法错误(注意:ES module 语法可能有警告,可跳过)
- [ ] **Step 3: 提交**
```bash
git add web/src/api/generate.js
git commit -m "feat(web): add generateJoke API function"
```
---
## Task 4: 前端路由配置
**Files:**
- Modify: `web/src/router/index.js:1-17`
- [ ] **Step 1: 添加 /generate 路由**
`web/src/router/index.js` 第 7 行后添加:
```javascript
{
path: '/generate',
name: 'Generate',
component: () => import('@/views/generate/index.vue')
},
```
- [ ] **Step 2: 提交**
```bash
git add web/src/router/index.js
git commit -m "feat(web): add /generate route"
```
---
## Task 5: 头部导航 AI 入口按钮
**Files:**
- Modify: `web/src/components/AppHeader.vue`
- [ ] **Step 1: 在导航栏添加 AI 生成按钮**
`web/src/components/AppHeader.vue``header-nav` 区域,找到 `random-btn` 后添加:
```vue
<router-link
to="/generate"
class="nav-item generate-btn"
:class="{ active: $route.path === '/generate' }"
title="AI 生成笑话"
>
<span>&#x2728;</span>
</router-link>
```
并在样式区域添加(在 `.random-btn:hover` 后):
```css
.generate-btn {
font-size: 16px;
padding: 8px 12px;
}
.generate-btn:hover {
background: rgba(147, 51, 234, 0.1);
}
.generate-btn.active {
background: rgba(147, 51, 234, 0.15);
color: #9333ea;
}
```
- [ ] **Step 2: 提交**
```bash
git add web/src/components/AppHeader.vue
git commit -m "feat(web): add AI generate button to header nav"
```
---
## Task 6: 生成器页面
**Files:**
- Create: `web/src/views/generate/index.vue`
- [ ] **Step 1: 创建生成器页面**
创建 `web/src/views/generate/index.vue`:
```vue
<template>
<div class="generate-page">
<h1 class="page-title">
<span class="emoji">&#x2728;</span>
智能笑话生成器
</h1>
<!-- 场景选择 -->
<div class="section">
<div class="section-label">选择场景可多选</div>
<div class="scenario-chips">
<el-check-tag
v-for="s in predefinedScenarios"
:key="s"
:checked="selectedScenarios.includes(s)"
@change="toggleScenario(s)"
class="scenario-chip"
>
{{ s }}
</el-check-tag>
</div>
</div>
<!-- 关键词输入 -->
<div class="section">
<div class="section-label">添加关键词可选</div>
<el-input
v-model="keywordInput"
placeholder="输入关键词,如:加班、相亲、熊孩子..."
@keyup.enter="addKeyword"
clearable
>
<template #append>
<el-button @click="addKeyword">添加</el-button>
</template>
</el-input>
<div v-if="keywords.length" class="keyword-tags">
<el-tag
v-for="(kw, i) in keywords"
:key="i"
closable
@close="removeKeyword(i)"
class="keyword-tag"
>
{{ kw }}
</el-tag>
</div>
</div>
<!-- 生成按钮 -->
<div class="generate-actions">
<el-button
type="primary"
size="large"
:loading="loading"
:disabled="loading"
@click="handleGenerate"
class="generate-btn"
>
<span v-if="!loading">&#x1F3B2; 开始生成</span>
<span v-else>生成中...</span>
</el-button>
<el-button v-if="generatedJoke" size="large" @click="resetForm">
清空
</el-button>
</div>
<!-- 生成结果 -->
<div v-if="generatedJoke" class="result-card">
<h2 class="result-title">{{ generatedJoke.title }}</h2>
<pre class="result-content">{{ generatedJoke.content }}</pre>
<div class="result-actions">
<el-button type="primary" @click="handleGenerate">
&#x1F504; 重新生成
</el-button>
<el-button @click="handleFavorite" :type="isFavorited ? 'danger' : 'default'">
<span v-if="!isFavorited">&#x2764; 收藏</span>
<span v-else>&#x2714; 已收藏</span>
</el-button>
</div>
</div>
<!-- 收藏列表 -->
<div v-if="favorites.length" class="favorites-section">
<h3>&#x1F4EC; 我的收藏 ({{ favorites.length }})</h3>
<div class="favorite-list">
<div
v-for="(fav, i) in favorites"
:key="fav.id"
class="favorite-item"
>
<div class="fav-title">{{ fav.title }}</div>
<div class="fav-content">{{ fav.content.substring(0, 100) }}...</div>
<div class="fav-meta">
<span class="fav-date">{{ formatDate(fav.created_at) }}</span>
<el-button type="danger" size="small" text @click="removeFavorite(i)">
删除
</el-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { generateJoke } from '@/api/generate'
const predefinedScenarios = ['职场', '校园', '社交', '家庭', '情感', '搞笑日常']
const selectedScenarios = ref([])
const keywordInput = ref('')
const keywords = ref([])
const loading = ref(false)
const generatedJoke = ref(null)
const isFavorited = ref(false)
// 收藏
const favorites = ref(JSON.parse(localStorage.getItem('joke_favorites') || '[]'))
const STORAGE_KEY = 'joke_favorites'
function toggleScenario(s) {
const idx = selectedScenarios.value.indexOf(s)
if (idx >= 0) {
selectedScenarios.value.splice(idx, 1)
} else {
selectedScenarios.value.push(s)
}
}
function addKeyword() {
const kw = keywordInput.value.trim()
if (kw && !keywords.value.includes(kw)) {
keywords.value.push(kw)
keywordInput.value = ''
}
}
function removeKeyword(i) {
keywords.value.splice(i, 1)
}
async function handleGenerate() {
loading.value = true
isFavorited.value = false
try {
const res = await generateJoke(keywords.value, selectedScenarios.value)
generatedJoke.value = res
// 检查是否已收藏
const exists = favorites.value.find(f => f.content === res.content)
isFavorited.value = !!exists
} catch (e) {
ElMessage.error(e.detail || '生成失败,请重试')
generatedJoke.value = null
} finally {
loading.value = false
}
}
function handleFavorite() {
if (!generatedJoke.value) return
// 检查是否已存在
const exists = favorites.value.findIndex(f => f.content === generatedJoke.value.content)
if (exists >= 0) {
ElMessage.info('已经收藏过了')
return
}
favorites.value.unshift({
id: Date.now().toString(),
title: generatedJoke.value.title,
content: generatedJoke.value.content,
created_at: new Date().toISOString()
})
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value))
isFavorited.value = true
ElMessage.success('收藏成功')
}
function removeFavorite(i) {
favorites.value.splice(i, 1)
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value))
// 检查当前生成的是否也从收藏中被删除了
if (generatedJoke.value) {
const stillExists = favorites.value.find(f => f.content === generatedJoke.value.content)
isFavorited.value = !!stillExists
}
}
function resetForm() {
generatedJoke.value = null
isFavorited.value = false
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString('zh-CN')
}
</script>
<style scoped>
.generate-page {
max-width: 720px;
margin: 0 auto;
padding: 40px 24px;
}
.page-title {
font-size: 28px;
font-weight: 800;
color: var(--text-primary);
text-align: center;
margin-bottom: 40px;
}
.emoji {
display: inline-block;
margin-right: 10px;
}
.section {
margin-bottom: 28px;
}
.section-label {
font-size: 15px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 12px;
}
.scenario-chips {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
:deep(.scenario-chip) {
padding: 8px 18px;
border-radius: 20px;
background: var(--bg-card);
border: 1.5px solid var(--border);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
:deep(.scenario-chip:hover) {
border-color: #9333ea;
}
:deep(.scenario-chip.is-checked) {
background: #9333ea;
border-color: #9333ea;
color: white;
}
.keyword-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.keyword-tag {
background: #f3e8ff;
border-color: #9333ea;
color: #9333ea;
}
.generate-actions {
display: flex;
justify-content: center;
gap: 16px;
margin-bottom: 32px;
}
:deep(.generate-btn) {
padding: 12px 40px;
font-size: 16px;
background: linear-gradient(135deg, #9333ea, #7c3aed);
border: none;
border-radius: 24px;
}
:deep(.generate-btn:hover) {
box-shadow: 0 4px 20px rgba(147, 51, 234, 0.4);
}
.result-card {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 32px;
margin-bottom: 32px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.result-title {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 16px;
text-align: center;
}
.result-content {
font-size: 16px;
line-height: 1.9;
color: var(--text-secondary);
white-space: pre-wrap;
margin-bottom: 24px;
}
.result-actions {
display: flex;
justify-content: center;
gap: 12px;
}
.favorites-section {
margin-top: 40px;
}
.favorites-section h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.favorite-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.favorite-item {
background: var(--bg-card);
border-radius: var(--radius);
padding: 16px 20px;
border: 1px solid var(--border);
}
.fav-title {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
}
.fav-content {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 8px;
line-height: 1.5;
}
.fav-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.fav-date {
font-size: 12px;
color: var(--text-muted);
}
</style>
```
- [ ] **Step 2: 验证页面加载**
启动服务后访问 `http://localhost:3000/generate`,验证:
- 页面正常显示
- 场景选择显示正常
- 关键词输入正常
- [ ] **Step 3: 提交**
```bash
git add web/src/views/generate/index.vue
git commit -m "feat(web): add AI joke generator page with favorites"
```
---
## Task 7: 更新 CLAUDE.md API 文档
**Files:**
- Modify: `CLAUDE.md`
- [ ] **Step 1: 添加新 API 文档**
在 API 接口一览部分添加:
```
- `POST /api/generate` — 公开:AI 生成笑话(keywords + scenarios
```
- [ ] **Step 2: 提交**
```bash
git add CLAUDE.md
git commit -m "docs: add /api/generate to API docs"
```
---
## 验证清单
完成所有任务后,请验证:
- [ ] API 文档 `http://localhost:8001/docs` 显示 `/api/generate` 接口
- [ ] 头部导航显示 AI 生成按钮
- [ ] 访问 `/generate` 页面正常
- [ ] 选择场景 + 输入关键词可正常调用 AI 生成
- [ ] 生成结果正确显示
- [ ] 收藏按钮可用,收藏数据正确存储到 localStorage
- [ ] 收藏列表正确显示,可删除收藏
- [ ] 页面样式正常(浅色/深色主题)
@@ -0,0 +1,151 @@
# 智能笑话生成器设计文档
**日期:** 2026-06-02
**状态:** 已批准
## 功能概述
用户点击导航栏的「AI生成」按钮,进入生成器页面,输入关键词和/或选择场景,自动调用 AI 生成一条相关笑话。用户可查看、收藏生成的笑话。
## 用户交互流程
1. 用户点击导航栏「AI生成」图标 → 跳转 `/generate` 页面
2. 用户选择场景(可选,支持多选)→ 用户输入关键词(可选)
3. 点击「开始生成」→ 调用 AI → 显示生成的笑话
4. 用户可执行以下操作:
- 重新生成(使用相同参数)
- 收藏到本地
- 查看我的收藏
- 删除收藏
## 页面结构
```
/generate
├── 场景选择区(chips,可多选 / 手动输入)
├── 关键词输入框
├── 生成按钮 [开始生成]
├── 结果展示区(生成后显示笑话卡片)
│ ├── 笑话内容
│ └── 操作按钮(重新生成 / 收藏 / 再来一条)
└── 我的收藏(localStorage 存储)
└── 收藏列表(可删除)
```
## 预设场景选项
- 职场
- 校园
- 社交
- 家庭
- 情感
- 搞笑日常
## API 设计
### POST /api/generate
生成 AI 笑话
**请求体:**
```json
{
"keywords": ["加班", "老板"],
"scenario": "职场"
}
```
**响应:**
```json
{
"title": "办公室的一天",
"content": "老板问:为什么每天早上都迟到?\n我说:因为堵车。\n老板说:那你为什么不早点出门?\n我说:因为早出门也会堵。\n老板沉默了。",
"created_at": "2026-06-02T12:00:00Z"
}
```
**错误响应:**
- 401`{ "detail": "AI 服务未配置" }`
- 500`{ "detail": "生成失败,请重试" }`
## AI Prompt
```
你是一位幽默大师,专门创作轻松搞笑的短笑话。
场景:{场景}
关键词:{关键词}
要求:
1. 根据场景和关键词创作一条原创笑话
2. 笑话要有反转或意外结局
3. 语言简洁,30-150字
4. 直接输出笑话内容,不需要解释
格式:
标题:xxx
内容:xxx
```
## 数据存储
**收藏数据(localStorage**
```json
{
"joke_favorites": [
{
"id": "uuid",
"title": "笑话标题",
"content": "笑话内容",
"created_at": "2026-06-02T12:00:00Z"
}
]
}
```
## 组件清单
| 组件 | 说明 |
|------|------|
| HeaderNav 生成按钮 | 导航栏添加 AI 生成图标入口 |
| GeneratePage | 生成器主页面 |
| ScenarioSelector | 场景选择 chips 组件 |
| KeywordInput | 关键词输入框 |
| JokeCard | 生成的笑话展示卡片(复用现有 JokeCard 样式) |
| FavoriteList | 我的收藏列表 |
| FavoriteItem | 收藏项(带删除按钮) |
## 错误处理
| 场景 | 处理方式 |
|------|----------|
| AI 未配置 | Toast 提示「系统暂未配置 AI 服务」 |
| 生成失败 | Toast 提示「生成失败,请重试」 |
| 网络错误 | Toast 提示「网络错误,请检查网络连接」 |
| 加载中 | 按钮显示 loading spinner,禁用点击 |
## 技术实现
### 后端
- 新增 `POST /api/generate` 接口
- 使用现有 AiSetting 配置调用 AI
- 返回结构化笑话数据
### 前端
- 新增 `web/src/views/generate/index.vue` 页面
- 新增 `web/src/api/generate.js` API 模块
- 复用现有组件样式
- localStorage 存储收藏数据
## 文件变更清单
### 新增文件
- `api/app/routers/generate.py` - 生成器后端路由
- `api/app/schemas/joke.py` - 添加 GenerateRequest/GenerateResponse schema
- `web/src/views/generate/index.vue` - 生成器页面
- `web/src/api/generate.js` - 前端 API
### 修改文件
- `api/main.py` - 注册新路由
- `web/src/router/index.js` - 添加 /generate 路由
- `web/src/components/Header.vue` - 添加生成器入口按钮(如有)
+149
View File
@@ -0,0 +1,149 @@
# 采集工具操作文档
## 概述
采集工具分为两个独立程序:
| 程序 | 功能 | 输出 |
|------|------|------|
| `site_finder.py` | 搜索 Bing,发现笑话聚合网站 | `joke_sites.json`(站点编号列表) |
| `site_crawler.py` | 按编号或 URL,深度翻页采集笑话 | 直接提交到 API(数据库) |
---
## 一、程序 1:发现笑话站点
搜索 Bing 获取笑话聚合网站,保存到 JSON 文件。
```bash
# 搜索并保存站点列表(默认输出到 joke_sites.json
python crawler/site_finder.py
# 指定关键词
python crawler/site_finder.py --keywords "笑话大全,冷笑话,段子"
# 指定输出文件
python crawler/site_finder.py --output my_sites.json
# 限制最多 10 个站点
python crawler/site_finder.py --max-sites 10
# 显示浏览器窗口
python crawler/site_finder.py --no-headless
```
**参数说明:**
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `--keywords` | 笑话大全,搞笑段子,冷笑话,... | 搜索关键词,逗号分隔 |
| `--output` | `joke_sites.json` | 输出 JSON 文件路径 |
| `--max-sites` | 15 | 最多保留几个站点 |
| `--no-headless` | — | 显示浏览器窗口(不加则无头模式) |
**输出格式(joke_sites.json):**
```json
[
{
"id": 1,
"url": "https://xiaohua.example.com",
"domain": "xiaohua.example.com",
"title": "笑话大全 - 爆笑来袭",
"found_at": "2026-05-31T10:30:00"
},
{
"id": 2,
"url": "https://joke.example.net",
"domain": "joke.example.net",
"title": "每日一笑",
"found_at": "2026-05-31T10:30:05"
}
]
```
已有站点会自动保留编号,新站点追加,按域名去重。
---
## 二、程序 2:深度采集指定站点
从 JSON 文件按编号加载站点,或直接指定 URL,深度采集该站点所有笑话并提交到 API。
### 按编号采集(推荐)
```bash
# 采集 #1 站点
python crawler/site_crawler.py --id 1
# 批量采集多个站点
python crawler/site_crawler.py --id 1,2,3
# 指定站点文件
python crawler/site_crawler.py --id 1 --sites-file my_sites.json
```
### 直接指定 URL
```bash
# 直接采集任意 URL
python crawler/site_crawler.py --url https://xiaohua.example.com
```
python crawler/site_crawler.py --url https://www.52xiaohua.com --no-headless
### 其他选项
```bash
# 显示浏览器窗口
python crawler/site_crawler.py --id 1 --no-headless
# 指定 API 地址
python crawler/site_crawler.py --id 1 --api-base http://192.168.1.10:8001
# 指定管理员账号
python crawler/site_crawler.py --id 1 --username admin --password admin123
```
**参数说明:**
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `--id` | — | 站点编号,多个用逗号分隔 |
| `--url` | — | 直接指定 URL(与 --id 二选一) |
| `--sites-file` | `joke_sites.json` | 站点列表文件路径 |
| `--api-base` | `http://localhost:8001` | API 服务地址 |
| `--username` | `admin` | 管理员用户名 |
| `--password` | `admin123` | 管理员密码 |
| `--no-headless` | — | 显示浏览器窗口 |
---
## 三、配合使用流程
```bash
# 1. 先启动 API
cd api && uvicorn main:app --reload --port 8001
# 2. 发现笑话站点(建议第一次用 --no-headless 观察效果)
cd crawler && python site_finder.py --no-headless
# 3. 查看生成的站点列表
cat joke_sites.json
# 4. 选择站点编号,开始深度采集
python site_crawler.py --id 1 --no-headless
# 5. 继续采集其他站点
python site_crawler.py --id 2,3 --no-headless
```
---
## 四、采集说明
- **翻页采集**:自动发现首页的翻页链接(`?page=N``/page/N/``index_N.html``下一页` 等),逐页抓取所有笑话
- **去重**:基于内容 MD5 哈希去重,同一笑话不会重复入库
- **容错**:单个页面失败自动重试 2 次;同一站点连续失败 3 次自动跳过
- **翻页限制**:每个站点最多采 30 页,避免无限抓取
- **状态**:采集的笑话默认 `pending`(待审核),需在后台管理页面审核通过后才会在前台展示
+98
View File
@@ -0,0 +1,98 @@
[
{
"url": "https://www.52xiaohua.com/xiaohua/1105.html",
"title": "100个经典幽默笑话大全,笑到肚子疼超级爆笑 - 爆笑笑话 ...",
"keyword": "笑话大全 网站",
"domain": "www.52xiaohua.com",
"id": 1,
"found_at": "2026-05-31T10:17:46"
},
{
"url": "https://xiaohua.zol.com.cn/",
"title": "笑话大全-爆笑经典笑话 冷笑话【ZOL笑话库】",
"keyword": "笑话大全 网站",
"domain": "xiaohua.zol.com.cn",
"id": 2,
"found_at": "2026-05-31T10:17:46"
},
{
"url": "https://www.yjbys.com/xiaohua/baoxiao/439774.html",
"title": "经典笑话大全爆笑100条",
"keyword": "笑话大全 网站",
"domain": "www.yjbys.com",
"id": 3,
"found_at": "2026-05-31T10:17:46"
},
{
"url": "https://zhilezhi.com/xiaohua",
"title": "笑话大全 - 幽默笑话和搞笑段子网站 | 知乐知",
"keyword": "笑话大全 网站",
"domain": "zhilezhi.com",
"id": 4,
"found_at": "2026-05-31T10:17:46"
},
{
"url": "https://xiaohua.hao86.com/",
"title": "笑话大全爆笑简短_史上最强笑话十则2026",
"keyword": "笑话大全 网站",
"domain": "xiaohua.hao86.com",
"id": 5,
"found_at": "2026-05-31T10:17:46"
},
{
"url": "https://www.douyin.com/video/7457017269722402067",
"title": "搞笑视频合集。 每日一笑,烦恼忘掉。#搞笑 #搞笑视频 #看 ...",
"keyword": "搞笑段子 网站",
"domain": "www.douyin.com",
"id": 6,
"found_at": "2026-05-31T10:17:46"
},
{
"url": "https://www.iqiyi.com/v_fhial680gw.html",
"title": "搞笑沙雕短片合集恶搞整蛊让你乐不停-爱奇艺",
"keyword": "搞笑段子 网站",
"domain": "www.iqiyi.com",
"id": 7,
"found_at": "2026-05-31T10:17:46"
},
{
"url": "https://www.qiushidabaike.com/",
"title": "笑话大全_恶搞囧事 - 糗事大百科",
"keyword": "搞笑段子 网站",
"domain": "www.qiushidabaike.com",
"id": 8,
"found_at": "2026-05-31T10:17:46"
},
{
"url": "https://doc.quark.cn/preview/tongyongshenghuo-shejiaoyule-lengxiaohua%2Fxiaohua/C247E8B69B4BFB30FA3A859CC1A0C94B",
"title": "100个经典冷笑话大全爆笑",
"keyword": "冷笑话 大全",
"domain": "doc.quark.cn",
"id": 9,
"found_at": "2026-05-31T10:17:46"
},
{
"url": "https://www.meiwengushi.com/100gejingdianlengxiaohuadaquan",
"title": "100个经典冷笑话大全",
"keyword": "冷笑话 大全",
"domain": "www.meiwengushi.com",
"id": 10,
"found_at": "2026-05-31T10:17:46"
},
{
"url": "https://www.thepaper.cn/newsDetail_forward_17848009",
"title": "20个幽默冷笑话,个个爆笑不断,记得分享给身边朋友们",
"keyword": "幽默笑话 网站",
"domain": "www.thepaper.cn",
"id": 11,
"found_at": "2026-05-31T10:17:46"
},
{
"url": "https://baike.sogou.com/v65352.htm",
"title": "幽默 _ 百科",
"keyword": "幽默笑话 网站",
"domain": "baike.sogou.com",
"id": 12,
"found_at": "2026-05-31T10:17:46"
}
]
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+100
View File
@@ -0,0 +1,100 @@
"""
笑话优化 CLI 工具。
循环检测所有笑话 → AI 质量检测 → AI 润色 → AI 评价分类。
用法:
python optimizer/main.py # 处理所有 pending/approved 笑话
python optimizer/main.py --status rejected # 只处理已拒绝的
python optimizer/main.py --limit 10 # 只处理前 10 条
python optimizer/main.py --id 1,2,3 # 指定 ID 处理
python optimizer/main.py --api-url http://localhost:8001
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from optimizer.optimizer import Optimizer
def parse_args():
parser = argparse.ArgumentParser(description="笑话优化工具 — 质量检测 / AI 润色 / 评价分类")
parser.add_argument(
"--api-url",
type=str,
default=os.getenv("API_URL", "http://localhost:8001"),
help="API 地址(默认: http://localhost:8001",
)
parser.add_argument(
"--username",
type=str,
default=os.getenv("API_USERNAME", "admin"),
help="管理员用户名(默认: admin",
)
parser.add_argument(
"--password",
type=str,
default=os.getenv("API_PASSWORD", "admin123"),
help="管理员密码(默认: admin123",
)
parser.add_argument(
"--status",
type=str,
default=None,
choices=["pending", "approved", "rejected"],
help="按状态筛选(默认: 全部)",
)
parser.add_argument(
"--limit",
type=int,
default=None,
help="最多处理 N 条(默认: 全部)",
)
parser.add_argument(
"--id",
type=str,
default=None,
dest="ids",
help="指定笑话 ID,逗号分隔(如: 1,2,3)",
)
return parser.parse_args()
def main():
args = parse_args()
ids = None
if args.ids:
ids = [int(x.strip()) for x in args.ids.split(",") if x.strip()]
print(f"=" * 50)
print(f"笑话优化工具")
print(f"API 地址: {args.api_url}")
print(f"筛选状态: {args.status or '全部'}")
if args.limit:
print(f"处理上限: {args.limit}")
if ids:
print(f"指定 ID: {ids}")
print(f"=" * 50)
optimizer = Optimizer(
api_base=args.api_url,
username=args.username,
password=args.password,
)
try:
optimizer.run(status=args.status, limit=args.limit, ids=ids)
except KeyboardInterrupt:
print("\n用户中断")
except Exception as e:
print(f"\n[!] 运行错误: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
+341
View File
@@ -0,0 +1,341 @@
"""笑话优化核心逻辑:质量检测 → AI 润色 → 评价分类。"""
import hashlib
import json
import sys
import time
import httpx
from openai import OpenAI
from optimizer.prompts import (
QUALITY_CHECK_SYSTEM_PROMPT,
QUALITY_CHECK_USER_PROMPT,
POLISH_SYSTEM_PROMPT,
POLISH_USER_PROMPT,
EVALUATE_SYSTEM_PROMPT,
EVALUATE_USER_PROMPT,
)
class Optimizer:
def __init__(self, api_base: str, username: str, password: str):
self.api_base = api_base.rstrip("/")
self.username = username
self.password = password
self.token = None
self.ai_client = None
self.model_name = ""
self.types = []
self.crowds = []
# 统计
self.stats = {"checked": 0, "rejected": 0, "polished": 0, "evaluated": 0, "skipped": 0}
# === API 认证 ===
def _login(self) -> str:
resp = httpx.post(
f"{self.api_base}/api/auth/login",
json={"username": self.username, "password": self.password},
timeout=30,
)
resp.raise_for_status()
return resp.json()["access_token"]
def _get(self, path: str) -> dict | list:
resp = httpx.get(
f"{self.api_base}{path}",
headers={"Authorization": f"Bearer {self.token}"},
timeout=30,
)
resp.raise_for_status()
return resp.json()
def _put(self, path: str, data: dict) -> dict:
resp = httpx.put(
f"{self.api_base}{path}",
json=data,
headers={"Authorization": f"Bearer {self.token}"},
timeout=30,
)
resp.raise_for_status()
return resp.json()
# === 初始化 ===
def setup(self):
print("[*] 正在登录...")
self.token = self._login()
print("[*] 登录成功")
ai_config = self._get("/api/admin/settings/active")
self.ai_client = OpenAI(
base_url=ai_config["api_base"],
api_key=ai_config["api_key"],
)
self.model_name = ai_config["model_name"]
print(f"[*] AI 模型: {self.model_name}")
self.types = self._get("/api/categories/types")
self.crowds = self._get("/api/categories/crowds")
print(f"[*] 分类: {len(self.types)} 种类型, {len(self.crowds)} 种人群")
# === 读取笑话 ===
def get_jokes(self, status: str | None = None, limit: int | None = None,
ids: list[int] | None = None) -> list[dict]:
"""从 API 分页读取笑话"""
if ids:
jokes = []
for jid in ids:
try:
j = self._get(f"/api/admin/jokes/{jid}")
jokes.append(j)
except Exception as e:
print(f" [!] 获取笑话 #{jid} 失败: {e}")
return jokes
page = 1
page_size = 100
all_jokes = []
while True:
try:
path = f"/api/admin/jokes?page={page}&page_size={page_size}"
if status:
path += f"&status={status}"
data = self._get(path)
items = data.get("items", [])
if not items:
break
all_jokes.extend(items)
print(f" [*] 已读取 {len(all_jokes)} 条...")
if limit and len(all_jokes) >= limit:
all_jokes = all_jokes[:limit]
break
page += 1
except Exception as e:
print(f" [!] 分页读取失败 (page={page}): {e}")
break
return all_jokes
# === Stage 1: 质量检测 ===
def quality_check(self, content: str) -> dict:
"""判断笑话是否有笑点,返回 {"has_punchline": bool, "reason": str}"""
resp = self.ai_client.chat.completions.create(
model=self.model_name,
messages=[
{"role": "system", "content": QUALITY_CHECK_SYSTEM_PROMPT},
{"role": "user", "content": QUALITY_CHECK_USER_PROMPT.format(content=content[:2000])},
],
temperature=0.3,
max_tokens=200,
)
raw = resp.choices[0].message.content.strip()
return self._parse_json(raw, {"has_punchline": True, "reason": ""})
# === Stage 2: AI 润色 ===
def polish(self, content: str) -> str:
"""润色笑话内容"""
resp = self.ai_client.chat.completions.create(
model=self.model_name,
messages=[
{"role": "system", "content": POLISH_SYSTEM_PROMPT},
{"role": "user", "content": POLISH_USER_PROMPT.format(content=content)},
],
temperature=0.8,
max_tokens=1024,
)
return resp.choices[0].message.content.strip()
# === Stage 3: 评价分类 ===
def evaluate(self, content: str) -> dict:
"""评价并分类,返回 {"types": [...], "crowds": [...], "score": int, "comment": str}"""
type_names = [t.get("name", "") for t in self.types]
crowd_names = [c.get("name", "") for c in self.crowds]
resp = self.ai_client.chat.completions.create(
model=self.model_name,
messages=[
{"role": "system", "content": EVALUATE_SYSTEM_PROMPT},
{"role": "user", "content": EVALUATE_USER_PROMPT.format(
content=content[:2000],
known_types=", ".join(type_names),
known_crowds=", ".join(crowd_names),
)},
],
temperature=0.3,
max_tokens=300,
)
raw = resp.choices[0].message.content.strip()
result = self._parse_json(raw, {"types": [], "crowds": [], "score": 5, "comment": ""})
# Backward compatibility: if LLM returns old single format, convert to array
if isinstance(result.get("types"), str):
result["types"] = [result["types"]] if result["types"] else []
if isinstance(result.get("crowds"), str):
result["crowds"] = [result["crowds"]] if result["crowds"] else []
return result
# === 辅助方法 ===
def _parse_json(self, raw: str, default: dict) -> dict:
"""安全解析 LLM 返回的 JSON"""
try:
data = json.loads(raw)
if isinstance(data, dict):
return data
return default
except json.JSONDecodeError:
if "```json" in raw:
raw = raw.split("```json")[1].split("```")[0]
elif "```" in raw:
raw = raw.split("```")[1].split("```")[0]
try:
return json.loads(raw.strip())
except Exception:
return default
def _get_type_id(self, type_name: str) -> int | None:
for t in self.types:
if t.get("name") == type_name:
return t.get("id")
return None
def _get_crowd_id(self, crowd_name: str) -> int | None:
for c in self.crowds:
if c.get("name") == crowd_name:
return c.get("id")
return None
# === 单条笑话处理(3 阶段) ===
def process_joke(self, joke: dict) -> bool:
"""处理单条笑话:质量检测 → 润色 → 评价分类,返回是否成功"""
joke_id = joke.get("id")
title = joke.get("title", "")
content = joke.get("content", "")
if not content:
print(f" [!] #{joke_id} 内容为空,跳过")
self.stats["skipped"] += 1
return False
print(f"\n {'='*40}")
print(f" 处理 #{joke_id}: {title[:30]}")
print(f" {'='*40}")
# Stage 1: 质量检测
print(f" [1/3] 质量检测...")
try:
check = self.quality_check(content)
if not check.get("has_punchline", True):
reason = check.get("reason", "无笑点")
print(f" [!] 无笑点: {reason}")
# 标记为 rejected
self._put(f"/api/admin/jokes/{joke_id}", {"status": "rejected"})
self.stats["rejected"] += 1
self.stats["checked"] += 1
return True # 处理完成(已拒绝)
print(f" [OK] 有笑点: {check.get('reason', '')}")
except Exception as e:
print(f" [!] 质量检测失败: {e},跳过本条")
self.stats["skipped"] += 1
return False
self.stats["checked"] += 1
# Stage 2: AI 润色
print(f" [2/3] AI 润色...")
try:
polished = self.polish(content)
if polished and polished != content:
print(f" [OK] 润色完成 ({len(content)} -> {len(polished)} 字)")
else:
print(f" [*] 润色后无变化")
except Exception as e:
print(f" [!] 润色失败: {e}")
polished = content # 润色失败时使用原文
# Stage 3: 评价分类
print(f" [3/3] 评价分类...")
try:
eval_result = self.evaluate(polished)
type_names = eval_result.get("types", [])
crowd_names = eval_result.get("crowds", [])
score = eval_result.get("score", 5)
print(f" [OK] 类型={type_names}, 人群={crowd_names}, 评分={score}/10")
except Exception as e:
print(f" [!] 评价分类失败: {e}")
type_names = []
crowd_names = []
score = None
# 保存更新
try:
update = {
"polished_content": polished,
"status": "approved" if (score or 5) >= 4 else "pending",
}
if type_names:
update["type_ids"] = []
for n in type_names:
tid = self._get_type_id(n)
if tid:
update["type_ids"].append(tid)
if not update["type_ids"]:
del update["type_ids"]
if crowd_names:
update["crowd_ids"] = []
for n in crowd_names:
cid = self._get_crowd_id(n)
if cid:
update["crowd_ids"].append(cid)
if not update["crowd_ids"]:
del update["crowd_ids"]
self._put(f"/api/admin/jokes/{joke_id}", update)
self.stats["polished"] += 1
self.stats["evaluated"] += 1
print(f" [OK] 更新成功")
return True
except Exception as e:
print(f" [!] 更新失败: {e}")
return False
# === 主循环 ===
def run(self, status: str | None = None, limit: int | None = None,
ids: list[int] | None = None):
"""主入口:读取笑话并逐个处理"""
print(f"\n>> 笑话优化模式启动")
print(f" 筛选状态: {status or '全部'}")
if limit:
print(f" 处理数量: {limit}")
if ids:
print(f" 指定 ID: {ids}")
self.setup()
jokes = self.get_jokes(status, limit, ids)
print(f"\n[*] 共读取 {len(jokes)} 条笑话,开始处理")
for idx, joke in enumerate(jokes):
print(f"\n --- 进度 {idx+1}/{len(jokes)} ---")
try:
self.process_joke(joke)
except KeyboardInterrupt:
print("\n用户中断")
break
except Exception as e:
print(f" [!] 处理异常: {e}")
self.stats["skipped"] += 1
# 每条间稍等,避免 API 限流
if idx < len(jokes) - 1:
time.sleep(1)
# 输出统计
print(f"\n{'='*40}")
print(f" 处理完成")
print(f" {'='*40}")
print(f" 检查: {self.stats['checked']}")
print(f" 拒绝(无笑点): {self.stats['rejected']}")
print(f" 润色: {self.stats['polished']}")
print(f" 评价分类: {self.stats['evaluated']}")
print(f" 跳过(失败): {self.stats['skipped']}")
print(f"{'='*40}")
+58
View File
@@ -0,0 +1,58 @@
"""AI 提示词模板 — 笑话质量检测、润色、评价分类。"""
# ===== Stage 1: 质量检测 =====
QUALITY_CHECK_SYSTEM_PROMPT = """你是一个幽默内容审核专家。判断以下内容是否是一个合格的笑话/段子。
合格标准(满足任一即可):
1. 有明确的笑点或反转(punchline)
2. 有幽默的语言表达或双关
3. 有意外结局或情理之中意料之外
不合格标准(符合任一即判定不合格):
1. 纯粹的事实陈述,没有任何幽默元素
2. 只是对话片段,没有笑点
3. 普通故事或叙事,没有幽默设计
4. 说教或道理阐述
5. 内容不完整或难以理解
始终返回 JSON 格式:{"has_punchline": true/false, "reason": "简要说明判断理由"}"""
QUALITY_CHECK_USER_PROMPT = """请判断以下内容是否为合格笑话:
{content}
返回 JSON 格式。"""
# ===== Stage 2: AI 润色 =====
POLISH_SYSTEM_PROMPT = """你是一个专业的幽默文案编辑。请润色以下笑话,要求:
1. 保持核心笑点不变
2. 优化语言表达,使其更通顺、更精炼
3. 增强节奏感和幽默效果,但不改变原意
4. 字数控制在原内容的 80%-120%
5. 不要添加额外解释或评论
6. 直接输出润色后的内容,不要加任何前缀"""
POLISH_USER_PROMPT = """请润色以下笑话:
{content}
只输出润色后的笑话内容。"""
# ===== Stage 3: 评价分类 =====
EVALUATE_SYSTEM_PROMPT = """你是一个笑话分类和评价专家。对给定的笑话进行分析,返回 JSON 格式的分类和评分结果。
要求:
1. types: 从提供的类型列表中选择所有匹配的类型名称(数组,可以选多个)
2. crowds: 从提供的人群列表中选择所有匹配的人群名称(数组,可以选多个)
3. score: 1-10 分,基于幽默程度、创意和表达效果
4. comment: 简短评语(10字以内)
始终返回 JSON 格式。"""
EVALUATE_USER_PROMPT = """笑话内容:
{content}
可选类型:{known_types}
可选人群:{known_crowds}
返回 JSON 格式:{{"types": ["类型1", "类型2"], "crowds": ["人群1", "人群2"], "score": 8, "comment": "简短评语"}}"""

Some files were not shown because too many files have changed in this diff Show More