Compare commits

...

4 Commits

Author SHA1 Message Date
dmy
2b21ddb990 fix: 初始化时不显示日期范围变化提示 2026-01-12 19:02:46 +08:00
dmy
3d269ce9d1 feat: 重构AI推荐功能并优化爬虫基础URL
重构前端AI推荐组件,移除本地过滤逻辑,改为从后端获取日期范围内的数据
新增AI服务模块,包含Prompt和推荐逻辑
为投标服务添加按日期范围查询接口
统一各爬虫服务的baseURL格式
2026-01-12 18:59:17 +08:00
dmy
61520e9ebf feat: 添加 AI 推荐功能
新增 AI 推荐模块,包括前端界面和后端服务
添加 OpenAI API 密钥配置
实现工程数据分析和推荐功能
2026-01-12 18:36:08 +08:00
dmy
3647b9a2e5 feat: 添加高优先级投标折叠功能并优化链接样式
为高优先级投标表格添加折叠/展开功能,当数据为空时自动折叠
优化链接样式,统一设置无下划线及悬停颜色
2026-01-12 15:52:58 +08:00
29 changed files with 520 additions and 196 deletions

5
.env
View File

@@ -11,4 +11,7 @@ PROXY_HOST=127.0.0.1
PROXY_PORT=3211 PROXY_PORT=3211
# 日志级别可选error, warn, info, debug, verbose # 日志级别可选error, warn, info, debug, verbose
LOG_LEVEL=info LOG_LEVEL=info
# OpenAI API Key (用于 AI 推荐)
ARK_API_KEY=a63d58b6-cf56-434b-8a42-5c781ba0822a

View File

@@ -13,4 +13,7 @@ PROXY_PORT=6000
# PROXY_PASSWORD= # PROXY_PASSWORD=
# 日志级别可选error, warn, info, debug, verbose # 日志级别可选error, warn, info, debug, verbose
LOG_LEVEL=info LOG_LEVEL=info
# OpenAI API Key (用于 AI 推荐)
ARK_API_KEY=your_openai_api_key_here

2
frontend/.env Normal file
View File

@@ -0,0 +1,2 @@
# ARK API Key (用于 AI 推荐)
VITE_ARK_API_KEY=a63d58b6-cf56-434b-8a42-5c781ba0822a

2
frontend/.env.example Normal file
View File

@@ -0,0 +1,2 @@
# OpenAI API Key (用于 AI 推荐)
VITE_OPENAI_API_KEY=your_openai_api_key_here

View File

@@ -12,6 +12,7 @@
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.2", "axios": "^1.13.2",
"element-plus": "^2.13.1", "element-plus": "^2.13.1",
"openai": "^6.16.0",
"vue": "^3.5.24" "vue": "^3.5.24"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -15,10 +15,14 @@
<span>Dashboard</span> <span>Dashboard</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="2"> <el-menu-item index="2">
<el-icon><MagicStick /></el-icon>
<span>Dashboard AI</span>
</el-menu-item>
<el-menu-item index="3">
<el-icon><Document /></el-icon> <el-icon><Document /></el-icon>
<span>Bids</span> <span>Bids</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="3"> <el-menu-item index="4">
<el-icon><Setting /></el-icon> <el-icon><Setting /></el-icon>
<span>Keywords</span> <span>Keywords</span>
</el-menu-item> </el-menu-item>
@@ -41,9 +45,14 @@
@refresh="fetchData" @refresh="fetchData"
/> />
<Bids <DashboardAI
v-if="activeIndex === '2'" v-if="activeIndex === '2'"
:bids="bids" :bids="bids"
/>
<Bids
v-if="activeIndex === '3'"
:bids="bids"
:source-options="sourceOptions" :source-options="sourceOptions"
:loading="loading" :loading="loading"
:total="total" :total="total"
@@ -51,7 +60,7 @@
/> />
<Keywords <Keywords
v-if="activeIndex === '3'" v-if="activeIndex === '4'"
:keywords="keywords" :keywords="keywords"
:loading="loading" :loading="loading"
@refresh="fetchData" @refresh="fetchData"
@@ -64,8 +73,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import axios from 'axios' import axios from 'axios'
import { DataBoard, Document, Setting } from '@element-plus/icons-vue' import { DataBoard, Document, Setting, MagicStick } from '@element-plus/icons-vue'
import Dashboard from './components/Dashboard.vue' import Dashboard from './components/Dashboard.vue'
import DashboardAI from './components/Dashboard-AI.vue'
import Bids from './components/Bids.vue' import Bids from './components/Bids.vue'
import Keywords from './components/Keywords.vue' import Keywords from './components/Keywords.vue'

View File

@@ -76,3 +76,14 @@ const handleSizeChange = (size: number) => {
emit('fetch', currentPage.value, pageSize.value, selectedSource.value || undefined) emit('fetch', currentPage.value, pageSize.value, selectedSource.value || undefined)
} }
</script> </script>
<style scoped>
a {
text-decoration: none;
color: inherit;
}
a:hover {
color: #409eff;
}
</style>

View File

@@ -0,0 +1,270 @@
<template>
<div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2 style="margin: 0;">Dashboard AI</h2>
<el-button type="primary" :loading="loading" @click="fetchAIRecommendations">
<el-icon style="margin-right: 5px"><MagicStick /></el-icon>
获取 AI 推荐
</el-button>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0;">选择日期范围</h3>
<div style="display: flex; gap: 10px;">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="To"
start-placeholder="Start Date"
end-placeholder="End Date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
clearable
style="width: 240px;"
/>
<el-button type="primary" @click="setLast3Days">3天</el-button>
<el-button type="primary" @click="setLast7Days">7天</el-button>
<el-button type="success" :loading="bidsLoading" @click="fetchBidsByDateRange">
<el-icon style="margin-right: 5px"><List /></el-icon>
列出时间范围内所有工程
</el-button>
</div>
</div>
<el-row :gutter="20">
<el-col :span="24">
<el-card class="box-card" shadow="hover">
<template #header>
<div class="card-header">
<span>AI 推荐项目</span>
<el-tag type="success">{{ aiRecommendations.length }} 个推荐</el-tag>
</div>
</template>
<div v-if="loading" style="text-align: center; padding: 40px;">
<el-icon class="is-loading" :size="30"><Loading /></el-icon>
<p style="margin-top: 10px; color: #909399;">AI 正在分析中...</p>
</div>
<div v-else-if="aiRecommendations.length === 0" style="text-align: center; padding: 40px; color: #909399;">
<el-icon :size="40"><InfoFilled /></el-icon>
<p style="margin-top: 10px;">暂无 AI 推荐项目</p>
<p style="font-size: 12px; margin-top: 5px;">点击上方按钮获取 AI 推荐</p>
</div>
<div v-else>
<el-table :data="aiRecommendations" style="width: 100%" size="small">
<el-table-column prop="title" label="项目名称">
<template #default="scope">
<a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a>
</template>
</el-table-column>
<el-table-column prop="source" label="来源" width="200" />
<el-table-column prop="confidence" label="推荐度" width="120">
<template #default="scope">
<el-tag :type="getConfidenceType(scope.row.confidence)">
{{ scope.row.confidence }}%
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</el-col>
</el-row>
<el-row v-if="showAllBids" :gutter="20" style="margin-top: 20px;">
<el-col :span="24">
<el-card class="box-card" shadow="hover">
<template #header>
<div class="card-header">
<span>时间范围内所有工程</span>
<el-tag type="info">{{ bidsByDateRange.length }} 个工程</el-tag>
</div>
</template>
<div v-if="bidsLoading" style="text-align: center; padding: 40px;">
<el-icon class="is-loading" :size="30"><Loading /></el-icon>
<p style="margin-top: 10px; color: #909399;">加载中...</p>
</div>
<div v-else-if="bidsByDateRange.length === 0" style="text-align: center; padding: 40px; color: #909399;">
<el-icon :size="40"><InfoFilled /></el-icon>
<p style="margin-top: 10px;">该时间范围内暂无工程</p>
</div>
<div v-else>
<el-table :data="bidsByDateRange" style="width: 100%" size="small">
<el-table-column prop="title" label="项目名称">
<template #default="scope">
<a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a>
</template>
</el-table-column>
<el-table-column prop="source" label="来源" width="200" />
<el-table-column prop="publishDate" label="发布日期" width="180">
<template #default="scope">
{{ formatDate(scope.row.publishDate) }}
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { MagicStick, Loading, InfoFilled, List } from '@element-plus/icons-vue'
interface AIRecommendation {
title: string
url: string
source: string
confidence: number
}
interface Props {
bids: any[]
}
const props = defineProps<Props>()
const loading = ref(false)
const aiRecommendations = ref<AIRecommendation[]>([])
const dateRange = ref<[string, string] | null>(null)
const showAllBids = ref(false)
const bidsLoading = ref(false)
const bidsByDateRange = ref<any[]>([])
// 设置日期范围为最近3天
const setLast3Days = () => {
const endDate = new Date()
const startDate = new Date()
startDate.setDate(startDate.getDate() - 2) // 最近3天包括今天
const formatDateForPicker = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
dateRange.value = [formatDateForPicker(startDate), formatDateForPicker(endDate)]
fetchBidsByDateRange()
}
// 设置日期范围为最近7天
const setLast7Days = () => {
const endDate = new Date()
const startDate = new Date()
startDate.setDate(startDate.getDate() - 6) // 最近7天包括今天
const formatDateForPicker = (date: Date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
dateRange.value = [formatDateForPicker(startDate), formatDateForPicker(endDate)]
fetchBidsByDateRange()
}
// 获取 AI 推荐项目
const fetchAIRecommendations = async () => {
loading.value = true
try {
// 准备发送给后端的数据(只包含 title
const bidsData = bidsByDateRange.value.map(bid => ({
title: bid.title
}))
// 调用后端 API
const response = await axios.post('/api/ai/recommendations', {
bids: bidsData
})
// 根据 title 从 bidsByDateRange 中更新 url 和 source
aiRecommendations.value = response.data.map((rec: any) => {
const bid = bidsByDateRange.value.find(b => b.title === rec.title)
return {
title: rec.title,
url: bid?.url || '',
source: bid?.source || '',
confidence: rec.confidence
}
})
ElMessage.success('AI 推荐获取成功')
} catch (error: any) {
ElMessage.error('获取 AI 推荐失败')
} finally {
loading.value = false
}
}
// 获取时间范围内的所有工程
const fetchBidsByDateRange = async () => {
if (!dateRange.value || dateRange.value.length !== 2) {
ElMessage.warning('请先选择日期范围')
return
}
showAllBids.value = true
bidsLoading.value = true
try {
const [startDate, endDate] = dateRange.value
// 检查 endDate 是否是今天
const today = new Date()
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
// 如果 endDate 是今天,则不传递 endDate 参数(不限制截止时间)
const params: any = { startDate }
if (endDate !== todayStr) {
params.endDate = endDate
}
const response = await axios.get('/api/bids/by-date-range', { params })
bidsByDateRange.value = response.data
ElMessage.success(`获取成功,共 ${response.data.length} 个工程`)
} catch (error: any) {
ElMessage.error('获取工程列表失败')
} finally {
bidsLoading.value = false
}
}
// 格式化日期,只显示年月日
const formatDate = (dateStr: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// 根据推荐度返回标签类型
const getConfidenceType = (confidence: number) => {
if (confidence >= 90) return 'success'
if (confidence >= 70) return 'warning'
return 'info'
}
// 初始化时设置默认日期范围为最近3天
setLast3Days()
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
a {
text-decoration: none;
color: inherit;
}
a:hover {
color: #409eff;
}
</style>

View File

@@ -11,22 +11,31 @@
<el-col :span="24"> <el-col :span="24">
<el-card class="box-card" shadow="hover"> <el-card class="box-card" shadow="hover">
<template #header> <template #header>
<div class="card-header"> <div class="card-header" @click="toggleHighPriority" style="cursor: pointer;">
<span>High Priority Bids</span> <span>High Priority Bids</span>
<el-tag type="danger">Top 10</el-tag> <div style="display: flex; align-items: center; gap: 10px;">
<el-tag type="danger">Top 10</el-tag>
<el-icon :style="{ transform: highPriorityCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 0.3s' }">
<ArrowDown />
</el-icon>
</div>
</div> </div>
</template> </template>
<el-table :data="highPriorityBids" style="width: 100%" size="small"> <el-collapse-transition>
<el-table-column prop="title" label="Title"> <div v-show="!highPriorityCollapsed">
<template #default="scope"> <el-table :data="highPriorityBids" style="width: 100%" size="small">
<a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a> <el-table-column prop="title" label="Title">
</template> <template #default="scope">
</el-table-column> <a :href="scope.row.url" target="_blank">{{ scope.row.title }}</a>
<el-table-column prop="source" label="Source" width="240" /> </template>
<el-table-column prop="publishDate" label="Date" width="120"> </el-table-column>
<template #default="scope">{{ formatDate(scope.row.publishDate) }}</template> <el-table-column prop="source" label="Source" width="240" />
</el-table-column> <el-table-column prop="publishDate" label="Date" width="120">
</el-table> <template #default="scope">{{ formatDate(scope.row.publishDate) }}</template>
</el-table-column>
</el-table>
</div>
</el-collapse-transition>
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
@@ -83,7 +92,7 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import axios from 'axios' import axios from 'axios'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue' import { Refresh, ArrowDown } from '@element-plus/icons-vue'
interface Props { interface Props {
todayBids: any[] todayBids: any[]
@@ -103,6 +112,20 @@ const emit = defineEmits<{
const selectedKeywords = ref<string[]>([]) const selectedKeywords = ref<string[]>([])
const dateRange = ref<[string, string] | null>(null) const dateRange = ref<[string, string] | null>(null)
const crawling = ref(false) const crawling = ref(false)
const highPriorityCollapsed = ref(false)
const isInitialized = ref(false)
// 切换 High Priority Bids 的折叠状态
const toggleHighPriority = () => {
highPriorityCollapsed.value = !highPriorityCollapsed.value
}
// 监听 highPriorityBids当没有数据时自动折叠
watch(() => props.highPriorityBids, (newBids) => {
if (newBids.length === 0) {
highPriorityCollapsed.value = true
}
}, { immediate: true })
// 从 localStorage 加载保存的关键字 // 从 localStorage 加载保存的关键字
const loadSavedKeywords = () => { const loadSavedKeywords = () => {
@@ -123,6 +146,12 @@ watch(selectedKeywords, (newKeywords) => {
// 监听日期范围变化并显示提示 // 监听日期范围变化并显示提示
watch(dateRange, () => { watch(dateRange, () => {
// 初始化时不显示提示
if (!isInitialized.value) {
isInitialized.value = true
return
}
const totalBids = props.todayBids.length const totalBids = props.todayBids.length
const filteredCount = filteredTodayBids.value.length const filteredCount = filteredTodayBids.value.length
@@ -207,9 +236,7 @@ const setLast3Days = () => {
const filteredCount = result.length const filteredCount = result.length
console.log('setLast3Days result, totalBids:', totalBids, 'filteredCount:', filteredCount) console.log('setLast3Days result, totalBids:', totalBids, 'filteredCount:', filteredCount)
if (totalBids === 0) { // 只在手动点击按钮时显示提示,初始化时不显示
ElMessage.warning('暂无数据,请先抓取数据')
}
} }
// 设置日期范围为最近7天 // 设置日期范围为最近7天
@@ -244,9 +271,7 @@ const setLast7Days = () => {
const filteredCount = result.length const filteredCount = result.length
console.log('setLast7Days result, totalBids:', totalBids, 'filteredCount:', filteredCount) console.log('setLast7Days result, totalBids:', totalBids, 'filteredCount:', filteredCount)
if (totalBids === 0) { // 只在手动点击按钮时显示提示,初始化时不显示
ElMessage.warning('暂无数据,请先抓取数据')
}
} }
const handleCrawl = async () => { const handleCrawl = async () => {
@@ -268,6 +293,9 @@ const handleCrawl = async () => {
// 初始化时加载保存的关键字 // 初始化时加载保存的关键字
loadSavedKeywords() loadSavedKeywords()
// 初始化时设置默认日期范围为最近3天
setLast3Days()
</script> </script>
<style scoped> <style scoped>
@@ -276,4 +304,13 @@ loadSavedKeywords()
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
a {
text-decoration: none;
color: inherit;
}
a:hover {
color: #409eff;
}
</style> </style>

View File

@@ -12,5 +12,5 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "../src/ai/Prompt.ts"]
} }

View File

@@ -20,7 +20,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"crawl": "ts-node -r tsconfig-paths/register src/scripts/crawl.ts", "crawl": "ts-node -r tsconfig-paths/register src/scripts/crawl.ts",
"update-source": "ts-node -r tsconfig-paths/register src/scripts/update-source.ts", "update-source": "ts-node -r tsconfig-paths/register src/scripts/update-source.ts",
"web":"npm --prefix frontend run build" "web": "npm --prefix frontend run build"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
@@ -35,6 +35,7 @@
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"mysql2": "^3.16.0", "mysql2": "^3.16.0",
"openai": "^6.16.0",
"puppeteer": "^24.34.0", "puppeteer": "^24.34.0",
"puppeteer-extra": "^3.3.6", "puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2", "puppeteer-extra-plugin-stealth": "^2.11.2",

1
src/ai/Prompt.ts Normal file
View File

@@ -0,0 +1 @@
export const PromptString: string = `先给我说你统计了多少个项目。我只对辽宁、山东、江苏、浙江、福建、广东、广西、河北这些地方的海上风电、海上光伏、漂浮式光伏、滩涂光伏、滩涂风电、渔光互补项目感兴趣。从我提供的这些工程里面找到我感兴趣的工程。如果没有推荐的,也要给出思考过程。`;

20
src/ai/ai.controller.ts Normal file
View File

@@ -0,0 +1,20 @@
import { Controller, Post, Body } from '@nestjs/common';
import { AiService } from './ai.service';
export class BidDataDto {
title: string;
}
export class BidsRequestDto {
bids: BidDataDto[];
}
@Controller('api/ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
@Post('recommendations')
async getRecommendations(@Body() request: BidsRequestDto) {
return this.aiService.getRecommendations(request.bids);
}
}

12
src/ai/ai.module.ts Normal file
View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
@Module({
imports: [ConfigModule],
controllers: [AiController],
providers: [AiService],
exports: [AiService],
})
export class AiModule {}

83
src/ai/ai.service.ts Normal file
View File

@@ -0,0 +1,83 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import OpenAI from 'openai';
import { PromptString } from './Prompt';
export interface BidDataDto {
title: string;
}
export interface AIRecommendation {
title: string;
url: string;
source: string;
confidence: number;
}
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
private openai: OpenAI;
constructor(private readonly configService: ConfigService) {
const apiKey = this.configService.get<string>('ARK_API_KEY');
this.openai = new OpenAI({
apiKey: apiKey || '',
baseURL: 'https://ark.cn-beijing.volces.com/api/v3',
timeout: 120000, // 120秒超时
});
}
async getRecommendations(bids: BidDataDto[]): Promise<AIRecommendation[]> {
this.logger.log('开始获取 AI 推荐');
this.logger.log(`发送给 AI 的数据数量: ${bids.length}`);
try {
const prompt =PromptString+ `请根据以下投标项目标题列表,推荐最值得关注的 5-10 个项目。请以 JSON 格式返回,格式如下:
[
{
"title": "项目标题",
"confidence": 推荐度(0-100的数字)
}
]
投标项目标题列表:
${JSON.stringify(bids.map(b => b.title), null, 2)}`;
this.logger.log('发给AI的内容',prompt);
const completion = await this.openai.chat.completions.create({
model: 'doubao-seed-1-6-lite-251015',
max_tokens: 32768,
reasoning_effort: 'medium',
messages: [
{
role: 'user',
content: prompt,
},
],
});
this.logger.log('AI API 响应成功');
const aiContent = completion.choices[0].message.content;
this.logger.log('AI 返回的内容:', aiContent);
if (!aiContent) {
throw new Error('AI 返回内容为空');
}
const jsonMatch = aiContent.match(/\[[\s\S]*\]/);
if (!jsonMatch) {
throw new Error('AI 返回格式不正确');
}
const recommendations = JSON.parse(jsonMatch[0]) as AIRecommendation[];
this.logger.log(`解析后的推荐结果: ${recommendations.length}`);
return recommendations;
} catch (error) {
this.logger.error('获取 AI 推荐失败:', error);
throw error;
}
}
}

View File

@@ -9,6 +9,7 @@ import { KeywordsModule } from './keywords/keywords.module';
import { CrawlerModule } from './crawler/crawler.module'; import { CrawlerModule } from './crawler/crawler.module';
import { TasksModule } from './schedule/schedule.module'; import { TasksModule } from './schedule/schedule.module';
import { LoggerModule } from './common/logger/logger.module'; import { LoggerModule } from './common/logger/logger.module';
import { AiModule } from './ai/ai.module';
@Module({ @Module({
imports: [ imports: [
@@ -16,7 +17,7 @@ import { LoggerModule } from './common/logger/logger.module';
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'frontend', 'dist'), rootPath: join(__dirname, '..', 'frontend', 'dist'),
exclude: ['/api*'], exclude: ['/api/(.*)'],
}), }),
LoggerModule, LoggerModule,
DatabaseModule, DatabaseModule,
@@ -24,6 +25,7 @@ import { LoggerModule } from './common/logger/logger.module';
KeywordsModule, KeywordsModule,
CrawlerModule, CrawlerModule,
TasksModule, TasksModule,
AiModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -24,4 +24,9 @@ export class BidsController {
getSources() { getSources() {
return this.bidsService.getSources(); return this.bidsService.getSources();
} }
@Get('by-date-range')
getByDateRange(@Query('startDate') startDate: string, @Query('endDate') endDate: string) {
return this.bidsService.getBidsByDateRange(startDate, endDate);
}
} }

View File

@@ -87,4 +87,22 @@ export class BidsService {
.orderBy('bid.publishDate', 'DESC') .orderBy('bid.publishDate', 'DESC')
.getMany(); .getMany();
} }
async getBidsByDateRange(startDate?: string, endDate?: string) {
const qb = this.bidRepository.createQueryBuilder('bid');
if (startDate) {
const start = new Date(startDate);
start.setHours(0, 0, 0, 0);
qb.andWhere('bid.publishDate >= :startDate', { startDate: start });
}
if (endDate) {
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
qb.andWhere('bid.publishDate <= :endDate', { endDate: end });
}
return qb.orderBy('bid.publishDate', 'DESC').getMany();
}
} }

View File

@@ -55,7 +55,7 @@ export interface CdtResult {
export const CdtCrawler = { export const CdtCrawler = {
name: '中国大唐集团电子商务平台', name: '中国大唐集团电子商务平台',
url: 'https://tang.cdt-ec.com/home/index.html', url: 'https://tang.cdt-ec.com/home/index.html',
baseUrl: 'https://tang.cdt-ec.com', baseUrl: 'https://tang.cdt-ec.com/',
async crawl(browser: puppeteer.Browser): Promise<CdtResult[]> { async crawl(browser: puppeteer.Browser): Promise<CdtResult[]> {
const logger = new Logger('CdtCrawler'); const logger = new Logger('CdtCrawler');

View File

@@ -50,7 +50,7 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
export const CeicCrawler = { export const CeicCrawler = {
name: '国家能源集团生态协作平台', name: '国家能源集团生态协作平台',
url: 'https://ceic.dlnyzb.com/3001', url: 'https://ceic.dlnyzb.com/3001',
baseUrl: 'https://ceic.dlnyzb.com', baseUrl: 'https://ceic.dlnyzb.com/',
async crawl(browser: puppeteer.Browser): Promise<ChdtpResult[]> { async crawl(browser: puppeteer.Browser): Promise<ChdtpResult[]> {
const logger = new Logger('CeicCrawler'); const logger = new Logger('CeicCrawler');

View File

@@ -55,7 +55,7 @@ export interface CgnpcResult {
export const CgnpcCrawler = { export const CgnpcCrawler = {
name: '中广核电子商务平台', name: '中广核电子商务平台',
url: 'https://ecp.cgnpc.com.cn/zbgg.html', url: 'https://ecp.cgnpc.com.cn/zbgg.html',
baseUrl: 'https://ecp.cgnpc.com.cn', baseUrl: 'https://ecp.cgnpc.com.cn/',
async crawl(browser: puppeteer.Browser): Promise<CgnpcResult[]> { async crawl(browser: puppeteer.Browser): Promise<CgnpcResult[]> {
const logger = new Logger('CgnpcCrawler'); const logger = new Logger('CgnpcCrawler');

View File

@@ -1,157 +0,0 @@
import { ChngCrawler } from './chng_target';
import puppeteer from 'puppeteer-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import type { Browser, Page } from 'puppeteer';
// 使用 stealth 插件增强反爬虫能力
puppeteer.use(StealthPlugin());
// Increase timeout to 180 seconds for slow sites and stealth mode
jest.setTimeout(180000);
// 获取代理配置
const getProxyArgs = (): string[] => {
const proxyHost = process.env.PROXY_HOST;
const proxyPort = process.env.PROXY_PORT;
const proxyUsername = process.env.PROXY_USERNAME;
const proxyPassword = process.env.PROXY_PASSWORD;
if (proxyHost && proxyPort) {
const args = [`--proxy-server=${proxyHost}:${proxyPort}`];
if (proxyUsername && proxyPassword) {
args.push(`--proxy-auth=${proxyUsername}:${proxyPassword}`);
}
return args;
}
return [];
};
// 模拟人类鼠标移动
async function simulateHumanMouseMovement(page: Page) {
const viewport = page.viewport();
if (!viewport) return;
const movements = 5 + Math.floor(Math.random() * 5); // 5-10次随机移动
for (let i = 0; i < movements; i++) {
const x = Math.floor(Math.random() * viewport.width);
const y = Math.floor(Math.random() * viewport.height);
await page.mouse.move(x, y, {
steps: 10 + Math.floor(Math.random() * 20) // 10-30步使移动更平滑
});
// 随机停顿 100-500ms
await new Promise(r => setTimeout(r, 100 + Math.random() * 400));
}
}
// 模拟人类滚动
async function simulateHumanScrolling(page: Page) {
const scrollCount = 3 + Math.floor(Math.random() * 5); // 3-7次滚动
for (let i = 0; i < scrollCount; i++) {
const scrollDistance = 100 + Math.floor(Math.random() * 400); // 100-500px
await page.evaluate((distance) => {
window.scrollBy({
top: distance,
behavior: 'smooth'
});
}, scrollDistance);
// 随机停顿 500-1500ms
await new Promise(r => setTimeout(r, 500 + Math.random() * 1000));
}
// 滚动回顶部
await page.evaluate(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
await new Promise(r => setTimeout(r, 1000));
}
describe('ChngCrawler Stealth Test (Headless Mode with Stealth Plugin)', () => {
let browser: Browser;
beforeAll(async () => {
const proxyArgs = getProxyArgs();
if (proxyArgs.length > 0) {
console.log('Using proxy:', proxyArgs.join(' '));
}
browser = await puppeteer.launch({
headless: true, // 使用 headless 模式
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-blink-features=AutomationControlled',
'--window-size=1920,1080',
'--disable-infobars',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--no-first-run',
'--no-zygote',
'--disable-gpu',
'--disable-features=VizDisplayCompositor',
'--disable-webgl',
...proxyArgs,
],
defaultViewport: null
});
});
afterAll(async () => {
if (browser) {
await browser.close();
}
});
it('should visit the website and list all found bid information with stealth plugin', async () => {
// 为此测试单独设置更长的超时时间
jest.setTimeout(180000);
console.log(`
Starting crawl for: ${ChngCrawler.name}`);
console.log(`Target URL: ${ChngCrawler.url}`);
console.log('Using puppeteer-extra-plugin-stealth for anti-detection');
console.log('Running in headless mode');
// 创建一个临时页面用于模拟人类行为
const tempPage = await browser.newPage();
await tempPage.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
// 模拟人类鼠标移动
console.log('Simulating human mouse movements...');
await simulateHumanMouseMovement(tempPage);
// 模拟人类滚动
console.log('Simulating human scrolling...');
await simulateHumanScrolling(tempPage);
await tempPage.close();
const results = await ChngCrawler.crawl(browser);
console.log(`
Successfully found ${results.length} items:
`);
console.log('----------------------------------------');
results.forEach((item, index) => {
console.log(`${index + 1}. [${item.publishDate.toLocaleDateString()}] ${item.title}`);
console.log(` Link: ${item.url}`);
console.log('----------------------------------------');
});
expect(results).toBeDefined();
expect(Array.isArray(results)).toBeTruthy();
if (results.length === 0) {
console.warn('Warning: No items found. The site might have detected the crawler or content is not loading properly.');
} else {
const firstItem = results[0];
expect(firstItem.title).toBeTruthy();
expect(firstItem.url).toMatch(/^https?:\/\//);
expect(firstItem.publishDate).toBeInstanceOf(Date);
}
});
});

View File

@@ -55,7 +55,7 @@ export interface CnncecpResult {
export const CnncecpCrawler = { export const CnncecpCrawler = {
name: '中核集团电子采购平台', name: '中核集团电子采购平台',
url: 'https://www.cnncecp.com/xzbgg/index.jhtml', url: 'https://www.cnncecp.com/xzbgg/index.jhtml',
baseUrl: 'https://www.cnncecp.com', baseUrl: 'https://www.cnncecp.com/',
async crawl(browser: puppeteer.Browser): Promise<CnncecpResult[]> { async crawl(browser: puppeteer.Browser): Promise<CnncecpResult[]> {
const logger = new Logger('CnncecpCrawler'); const logger = new Logger('CnncecpCrawler');

View File

@@ -55,7 +55,7 @@ export interface CnoocResult {
export const CnoocCrawler = { export const CnoocCrawler = {
name: '中海油招标平台', name: '中海油招标平台',
url: 'https://buy.cnooc.com.cn/cbjyweb/001/001001/moreinfo.html', url: 'https://buy.cnooc.com.cn/cbjyweb/001/001001/moreinfo.html',
baseUrl: 'https://buy.cnooc.com.cn', baseUrl: 'https://buy.cnooc.com.cn/',
async crawl(browser: puppeteer.Browser): Promise<CnoocResult[]> { async crawl(browser: puppeteer.Browser): Promise<CnoocResult[]> {
const logger = new Logger('CnoocCrawler'); const logger = new Logger('CnoocCrawler');

View File

@@ -55,7 +55,7 @@ export interface EpsResult {
export const EpsCrawler = { export const EpsCrawler = {
name: '中国三峡集团电子商务平台', name: '中国三峡集团电子商务平台',
url: 'https://eps.ctg.com.cn/cms/channel/1ywgg1/index.htm', url: 'https://eps.ctg.com.cn/cms/channel/1ywgg1/index.htm',
baseUrl: 'https://eps.ctg.com.cn', baseUrl: 'https://eps.ctg.com.cn/',
async crawl(browser: puppeteer.Browser): Promise<EpsResult[]> { async crawl(browser: puppeteer.Browser): Promise<EpsResult[]> {
const logger = new Logger('EpsCrawler'); const logger = new Logger('EpsCrawler');

View File

@@ -54,7 +54,7 @@ export interface EspicResult {
export const EspicCrawler = { export const EspicCrawler = {
name: '电能e招采平台国电投', name: '电能e招采平台国电投',
baseUrl: 'https://ebid.espic.com.cn', baseUrl: 'https://ebid.espic.com.cn/',
// 生成动态 URL使用当前日期 // 生成动态 URL使用当前日期
getUrl(page: number = 1): string { getUrl(page: number = 1): string {

View File

@@ -55,7 +55,7 @@ export interface PowerbeijingResult {
export const PowerbeijingCrawler = { export const PowerbeijingCrawler = {
name: '北京京能电子商务平台', name: '北京京能电子商务平台',
url: 'https://www.powerbeijing-ec.com/jncms/search/bulletin.html?dates=300&categoryId=2&tabName=%E6%8B%9B%E6%A0%87%E5%85%AC%E5%91%8A&page=1', url: 'https://www.powerbeijing-ec.com/jncms/search/bulletin.html?dates=300&categoryId=2&tabName=%E6%8B%9B%E6%A0%87%E5%85%AC%E5%91%8A&page=1',
baseUrl: 'https://www.powerbeijing-ec.com', baseUrl: 'https://www.powerbeijing-ec.com/',
async crawl(browser: puppeteer.Browser): Promise<PowerbeijingResult[]> { async crawl(browser: puppeteer.Browser): Promise<PowerbeijingResult[]> {
const logger = new Logger('PowerbeijingCrawler'); const logger = new Logger('PowerbeijingCrawler');

View File

@@ -55,7 +55,7 @@ export interface SdiccResult {
export const SdiccCrawler = { export const SdiccCrawler = {
name: '国投集团电子采购平台', name: '国投集团电子采购平台',
url: 'https://www.sdicc.com.cn/cgxx/ggList', url: 'https://www.sdicc.com.cn/cgxx/ggList',
baseUrl: 'https://www.sdicc.com.cn', baseUrl: 'https://www.sdicc.com.cn/',
async crawl(browser: puppeteer.Browser): Promise<SdiccResult[]> { async crawl(browser: puppeteer.Browser): Promise<SdiccResult[]> {
const logger = new Logger('SdiccCrawler'); const logger = new Logger('SdiccCrawler');

View File

@@ -50,7 +50,7 @@ async function simulateHumanScrolling(page: puppeteer.Page) {
export const SzecpCrawler = { export const SzecpCrawler = {
name: '华润守正采购交易平台', name: '华润守正采购交易平台',
url: 'https://www.szecp.com.cn/first_zbgg/index.html', url: 'https://www.szecp.com.cn/first_zbgg/index.html',
baseUrl: 'https://www.szecp.com.cn', baseUrl: 'https://www.szecp.com.cn/',
async crawl(browser: puppeteer.Browser): Promise<ChdtpResult[]> { async crawl(browser: puppeteer.Browser): Promise<ChdtpResult[]> {
const logger = new Logger('SzecpCrawler'); const logger = new Logger('SzecpCrawler');