You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

641 lines
19 KiB

3 weeks ago
<template>
<view class="page-wrap profile-bg crawler-page">
<view class="card form-card">
<view class="field">
<text class="form-label">入库类型 *</text>
<picker class="picker-field" :range="typeLabels" :value="typeIndex" @change="onTypeChange">
<view class="select">{{ typeLabels[typeIndex] }}</view>
</picker>
</view>
3 weeks ago
<view class="field">
<text class="form-label">爬虫地址选填</text>
<picker
class="picker-field"
:range="addressLabels"
:value="addressIndex"
@change="onAddressPick"
>
<view class="select">{{ addressLabels[addressIndex] }}</view>
</picker>
</view>
3 weeks ago
<view class="field">
<text class="form-label">目标地址 *</text>
<input
v-model="form.request_url"
class="input"
placeholder="https:// 列表页或详情页"
placeholder-class="input-placeholder"
@blur="onUrlBlur"
/>
<text v-if="resolving" class="hint"></text>
3 weeks ago
<text v-else-if="resolvedName" class="hint">已识别{{ resolvedName }}{{ resolvedAdapter ? `${formatAdapterLabel(resolvedAdapter)}` : '' }}</text>
<text v-if="selectedAddressHint" class="hint">{{ selectedAddressHint }}</text>
3 weeks ago
</view>
3 weeks ago
<view class="field">
3 weeks ago
<text class="form-label">搜索关键词选填</text>
<textarea
v-model="keyword"
class="textarea"
maxlength="500"
3 weeks ago
:placeholder="keywordPlaceholder"
3 weeks ago
placeholder-class="input-placeholder"
/>
</view>
3 weeks ago
<view v-if="form.target_type === 'paper' || form.target_type === 'industry_news' || form.target_type === 'teacher'" class="field">
3 weeks ago
<text class="form-label">抓取页数</text>
<input v-model.number="maxPages" class="input" type="number" :disabled="isAiSjtuResearchCenter" />
3 weeks ago
<text v-if="form.target_type === 'paper'" class="hint">arXiv 50 </text>
<text v-if="form.target_type === 'paper'" class="hint"></text>
<text v-else-if="form.target_type === 'industry_news'" class="hint">虎嗅投资界清科等列表页建议 35 正文将自动补全入库</text>
<text v-else-if="isAiSjtuResearchCenter" class="hint">交大 AI 研究院研究中心为 API 一次性拉取无需分页</text>
3 weeks ago
<text v-else-if="form.target_type === 'teacher'" class="hint">多页列表 Sudy CMS博山 CMS交大 tsites请适当增大页数</text>
<text v-else-if="form.target_type === 'teacher' && !isAiSjtuResearchCenter" class="hint">大批量抓取时仅部分老师会访问主页补邮箱避免请求超时</text>
3 weeks ago
</view>
<view class="field">
<text class="form-label">条数上限</text>
<input v-model.number="maxResults" class="input" type="number" />
3 weeks ago
<text v-if="form.target_type === 'paper'" class="hint"> 200 </text>
<text v-else-if="form.target_type === 'teacher'" class="hint">师资列表最多 500 </text>
<text v-if="isAiSjtuResearchCenter" class="hint"></text>
3 weeks ago
<text v-else-if="form.target_type === 'industry_news'" class="hint">资讯最多 50 URL 已入库将跳过不重写正文空正文需先删旧记录再重抓</text>
<text v-if="form.target_type === 'industry_news'" class="hint">使 HTML</text>
3 weeks ago
</view>
<button class="btn btn-primary submit-btn" :loading="submitting" @tap="submit"></button>
</view>
<view v-if="lastResult" class="card result-card">
<text class="result-title">抓取结果</text>
<text class="result-line">状态{{ lastResult.status === 'completed' ? '已完成' : lastResult.status }}</text>
<text v-if="lastResult.source_name" class="result-line">{{ lastResult.source_name }}</text>
3 weeks ago
<text v-if="lastResult.adapter_code" class="result-line">{{ formatAdapterLabel(lastResult.adapter_code) }}</text>
3 weeks ago
<text v-if="lastResult.result_summary" class="result-line">{{ lastResult.result_summary }}</text>
<text v-else class="result-line">已入库 {{ lastResult.items_imported ?? 0 }} </text>
<text v-if="lastResult.teacher_duplicates_skipped" class="result-line hint-line">
跳过 {{ lastResult.teacher_duplicates_skipped }} 老师库中已有相同邮箱或同校同院系同名老师
</text>
3 weeks ago
<text v-if="lastResult.items_fetched" class="result-line"> {{ lastResult.items_fetched }} </text>
</view>
</view>
</template>
<script lang="ts">
import { getShareAppMessage, getShareTimelineMessage } from '@/utils/page-share-handlers'
export default {
onShareAppMessage: getShareAppMessage,
onShareTimeline: getShareTimelineMessage,
}
</script>
<script setup lang="ts">
3 weeks ago
import { computed, reactive, ref } from 'vue'
3 weeks ago
import { onShow } from '@dcloudio/uni-app'
import { crawlerApi, type CrawlTargetType } from '@/api/crawler'
3 weeks ago
import { crawlAddressApi, type CrawlAddressOption } from '@/api/crawl-addresses'
3 weeks ago
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const typeOptions: { label: string; value: CrawlTargetType }[] = [
{ label: '论文 → 论文库', value: 'paper' },
{ label: '行业资讯 → 资讯管理', value: 'industry_news' },
{ label: '老师库 → 老师库', value: 'teacher' },
]
const typeLabels = typeOptions.map((item) => item.label)
const typeIndex = ref(0)
const keyword = ref('')
3 weeks ago
const maxPages = ref(1)
const maxResults = ref(50)
3 weeks ago
const resolving = ref(false)
const submitting = ref(false)
const resolvedName = ref('')
3 weeks ago
const resolvedAdapter = ref('')
const resolvedUrl = ref('')
const ADAPTER_LABELS: Record<string, string> = {
huxiu_html: '虎嗅 API',
pedaily_html: '投资界',
faculty_list_html: '师资 HTML',
generic_news_html: '通用资讯',
arxiv_api: 'arXiv API',
ai_sjtu_research_center_api: '交大 AI 研究中心',
3 weeks ago
}
function formatAdapterLabel(code: string) {
return ADAPTER_LABELS[code] || code
}
const isAiSjtuResearchCenter = computed(
() => resolvedAdapter.value === 'ai_sjtu_research_center_api',
)
3 weeks ago
const lastResult = ref<Awaited<ReturnType<typeof crawlerApi.submit>> | null>(null)
3 weeks ago
const addressOptions = ref<CrawlAddressOption[]>([])
const addressIndex = ref(0)
const selectedCrawlAddressId = ref<number | null>(null)
3 weeks ago
const crawlDefaults = ref<{
category_dict_item_id?: number
category_label?: string
source_name?: string
university_id?: number
university_name?: string
department?: string
adapter_code?: string
3 weeks ago
}>({})
const addressLabels = computed(() => [
'请选择爬虫地址',
...addressOptions.value.map((item) => item.name),
])
const keywordPlaceholder = computed(() => {
if (form.target_type === 'paper') {
return '多个关键词用逗号或换行分隔graph neural, AI'
}
if (form.target_type === 'industry_news') {
return '多个关键词用空格、逗号或换行分隔,如:融资 科创板 AI'
}
return '多个关键词用空格、逗号或换行分隔'
})
const selectedAddressHint = computed(() => {
const parts: string[] = []
if (crawlDefaults.value.category_label) {
parts.push(`资讯分类:${crawlDefaults.value.category_label}`)
}
if (crawlDefaults.value.department) {
parts.push(`默认院系:${crawlDefaults.value.department}`)
}
if (crawlDefaults.value.university_name) {
parts.push(`默认高校:${crawlDefaults.value.university_name}`)
}
if (crawlDefaults.value.adapter_code) {
parts.push(`采集适配器:${formatAdapterLabel(crawlDefaults.value.adapter_code)}`)
}
3 weeks ago
return parts.length > 0 ? parts.join('') : ''
})
3 weeks ago
const form = reactive({
target_type: 'paper' as CrawlTargetType,
3 weeks ago
request_url: 'https://arxiv.org/',
3 weeks ago
})
3 weeks ago
function defaultUrl(type: CrawlTargetType) {
if (type === 'paper') return 'https://arxiv.org/'
if (type === 'teacher') return ''
return 'https://www.pedaily.cn/all/'
}
function applyTypeDefaults(type: CrawlTargetType) {
if (type === 'teacher') {
maxResults.value = 200
maxPages.value = 5
} else if (type === 'industry_news') {
maxResults.value = 30
maxPages.value = 5
} else {
maxResults.value = 50
maxPages.value = 1
}
}
function clampParams(type: CrawlTargetType) {
if (type === 'paper') {
maxResults.value = Math.min(200, Math.max(1, Number(maxResults.value) || 50))
maxPages.value = Math.min(20, Math.max(1, Number(maxPages.value) || 1))
} else if (type === 'teacher') {
maxResults.value = Math.min(500, Math.max(1, Number(maxResults.value) || 200))
maxPages.value = Math.min(50, Math.max(1, Number(maxPages.value) || 5))
} else {
maxResults.value = Math.min(50, Math.max(1, Number(maxResults.value) || 30))
maxPages.value = Math.min(50, Math.max(1, Number(maxPages.value) || 5))
}
}
function normalizeUrl(url: string) {
const trimmed = url.trim()
if (!trimmed) return ''
return trimmed.startsWith('http') ? trimmed : `https://${trimmed}`
}
async function loadAddresses() {
try {
addressOptions.value = await crawlAddressApi.options(form.target_type)
} catch {
addressOptions.value = []
}
addressIndex.value = 0
selectedCrawlAddressId.value = null
3 weeks ago
crawlDefaults.value = {}
}
function applyCrawlDefaults(addr: CrawlAddressOption) {
crawlDefaults.value = {}
if (addr.category_dict_item_id) {
crawlDefaults.value.category_dict_item_id = addr.category_dict_item_id
}
if (addr.category_label) {
crawlDefaults.value.category_label = addr.category_label
}
crawlDefaults.value.source_name = addr.name
if (addr.university_id) {
crawlDefaults.value.university_id = addr.university_id
}
if (addr.university_name) {
crawlDefaults.value.university_name = addr.university_name
}
if (addr.department) {
crawlDefaults.value.department = addr.department
}
if (addr.adapter_code) {
crawlDefaults.value.adapter_code = addr.adapter_code
}
3 weeks ago
}
function syncFromCrawlAddress(url: string, options?: { fillKeyword?: boolean }) {
const normalized = normalizeUrl(url)
const matched = addressOptions.value.find(
(item) => normalizeUrl(item.request_url) === normalized,
)
if (!matched) {
addressIndex.value = 0
selectedCrawlAddressId.value = null
3 weeks ago
crawlDefaults.value = {}
return
}
addressIndex.value = addressOptions.value.indexOf(matched) + 1
selectedCrawlAddressId.value = matched.id
3 weeks ago
if (options?.fillKeyword && matched.keyword) {
keyword.value = matched.keyword
}
applyCrawlDefaults(matched)
}
3 weeks ago
onShow(async () => {
if (!userStore.isLoggedIn) {
uni.navigateTo({ url: '/subpkg/login/index' })
return
}
try {
await userStore.fetchMe()
} catch {
// ignore
}
if (!userStore.isStaff) {
uni.showToast({ title: '无权使用数据爬虫', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1200)
3 weeks ago
return
}
applyTypeDefaults(form.target_type)
await loadAddresses()
if (form.request_url.trim()) {
void onUrlBlur()
3 weeks ago
}
})
function onTypeChange(event: UniHelper.PickerChangeEvent) {
typeIndex.value = Number(event.detail.value)
form.target_type = typeOptions[typeIndex.value]?.value || 'paper'
resolvedName.value = ''
3 weeks ago
resolvedAdapter.value = ''
resolvedUrl.value = ''
form.request_url = defaultUrl(form.target_type)
keyword.value = ''
applyTypeDefaults(form.target_type)
void loadAddresses()
}
function onAddressPick(event: UniHelper.PickerChangeEvent) {
addressIndex.value = Number(event.detail.value)
if (addressIndex.value <= 0) {
selectedCrawlAddressId.value = null
3 weeks ago
crawlDefaults.value = {}
return
}
const addr = addressOptions.value[addressIndex.value - 1]
if (!addr) return
selectedCrawlAddressId.value = addr.id
3 weeks ago
form.request_url = addr.request_url
if (addr.keyword) {
keyword.value = addr.keyword
3 weeks ago
}
3 weeks ago
applyCrawlDefaults(addr)
void onUrlBlur()
3 weeks ago
}
async function onUrlBlur() {
const url = form.request_url.trim()
if (!url) {
resolvedName.value = ''
3 weeks ago
resolvedAdapter.value = ''
resolvedUrl.value = ''
addressIndex.value = 0
selectedCrawlAddressId.value = null
3 weeks ago
crawlDefaults.value = {}
3 weeks ago
return
}
3 weeks ago
const normalized = normalizeUrl(url)
if (normalized !== url) {
form.request_url = normalized
}
const matched = addressOptions.value.some(
(item) => normalizeUrl(item.request_url) === normalized,
)
syncFromCrawlAddress(normalized, { fillKeyword: matched })
3 weeks ago
resolving.value = true
try {
const res = await crawlerApi.resolveUrl({
3 weeks ago
request_url: normalized,
3 weeks ago
target_type: form.target_type,
crawl_address_id: selectedCrawlAddressId.value ?? undefined,
3 weeks ago
})
resolvedName.value = res.source_name
3 weeks ago
resolvedAdapter.value = res.adapter_code || ''
resolvedUrl.value = normalized
if (res.adapter_code === 'ai_sjtu_research_center_api') {
maxPages.value = 1
maxResults.value = Math.max(maxResults.value, 200)
}
3 weeks ago
} catch (error) {
resolvedName.value = ''
3 weeks ago
resolvedAdapter.value = ''
resolvedUrl.value = ''
3 weeks ago
uni.showToast({
title: error instanceof Error ? error.message : '无法识别该地址',
icon: 'none',
})
} finally {
resolving.value = false
}
}
3 weeks ago
async function ensureResolved(): Promise<boolean> {
const url = form.request_url.trim()
if (!url) {
uni.showToast({ title: '请填写目标地址', icon: 'none' })
return false
}
const normalized = normalizeUrl(url)
if (normalized !== url) {
form.request_url = normalized
}
if (!resolvedName.value || resolvedUrl.value !== normalized) {
await onUrlBlur()
}
return !!resolvedName.value
}
3 weeks ago
function buildParams(): Record<string, unknown> {
3 weeks ago
clampParams(form.target_type)
3 weeks ago
const params: Record<string, unknown> = {}
3 weeks ago
params.keyword = keyword.value.trim()
3 weeks ago
params.max_results = maxResults.value
3 weeks ago
if (form.target_type === 'paper' || form.target_type === 'industry_news' || form.target_type === 'teacher') {
3 weeks ago
params.max_pages = maxPages.value
}
return params
}
function buildToastMessage(result: Awaited<ReturnType<typeof crawlerApi.submit>>): string {
if (result.target_type === 'teacher') {
const imported = result.items_imported ?? 0
const skipped = result.teacher_duplicates_skipped ?? 0
if (skipped > 0) {
return `已入库${imported}位,跳过${skipped}`
}
return `已入库${imported}位老师`
3 weeks ago
}
3 weeks ago
if (result.target_type === 'paper') {
const papers = result.papers_imported ?? result.items_imported ?? 0
return `已入库${papers}篇论文`
3 weeks ago
}
3 weeks ago
if (result.target_type === 'industry_news') {
const news = result.items_imported ?? 0
return `已入库${news}条资讯`
3 weeks ago
}
return '抓取完成'
3 weeks ago
}
3 weeks ago
function resolveNewsSourceName(url: string): string {
if (crawlDefaults.value.source_name) {
return crawlDefaults.value.source_name
}
const normalized = normalizeUrl(url)
const matched = addressOptions.value.find(
(item) => normalizeUrl(item.request_url) === normalized,
)
return matched?.name || ''
}
function buildNewsDefaults(url: string) {
const defaults: NonNullable<Parameters<typeof crawlerApi.submit>[0]['news_defaults']> = {}
if (crawlDefaults.value.category_dict_item_id) {
defaults.category_dict_item_id = crawlDefaults.value.category_dict_item_id
}
const source = resolveNewsSourceName(url)
if (source) {
defaults.source = source
}
return Object.keys(defaults).length > 0 ? defaults : undefined
}
3 weeks ago
async function submit() {
3 weeks ago
if (!(await ensureResolved())) {
uni.showToast({ title: '无法识别该地址,请检查入库类型与 URL', icon: 'none' })
3 weeks ago
return
}
submitting.value = true
try {
3 weeks ago
syncFromCrawlAddress(form.request_url, { fillKeyword: false })
const normalizedUrl = normalizeUrl(form.request_url)
const payload: Parameters<typeof crawlerApi.submit>[0] = {
3 weeks ago
target_type: form.target_type,
3 weeks ago
request_url: normalizedUrl,
3 weeks ago
params: buildParams(),
3 weeks ago
}
if (selectedCrawlAddressId.value) {
payload.crawl_address_id = selectedCrawlAddressId.value
}
3 weeks ago
if (form.target_type === 'industry_news') {
const newsDefaults = buildNewsDefaults(normalizedUrl)
if (newsDefaults) {
payload.news_defaults = newsDefaults
}
}
if (form.target_type === 'teacher') {
const teacherDefaults: NonNullable<Parameters<typeof crawlerApi.submit>[0]['teacher_defaults']> = {}
if (crawlDefaults.value.university_id) {
teacherDefaults.university_id = crawlDefaults.value.university_id
}
if (crawlDefaults.value.department) {
teacherDefaults.department = crawlDefaults.value.department
}
if (Object.keys(teacherDefaults).length > 0) {
payload.teacher_defaults = teacherDefaults
}
}
lastResult.value = await crawlerApi.submit(payload)
3 weeks ago
uni.showToast({
title: buildToastMessage(lastResult.value),
icon: 'none',
duration: 2500,
3 weeks ago
})
} catch (error) {
uni.showToast({
title: error instanceof Error ? error.message : '抓取失败',
icon: 'none',
})
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
@import '@/styles/page.scss';
@import '@/styles/card.scss';
@import '@/styles/tokens.scss';
.crawler-page {
box-sizing: border-box;
}
.form-card,
.result-card {
box-sizing: border-box;
width: 100%;
margin-bottom: $section-gap;
overflow: hidden;
}
.form-card {
padding: 32rpx;
}
.result-card {
padding: 32rpx;
}
.field {
box-sizing: border-box;
width: 100%;
margin-bottom: 24rpx;
}
.field:last-of-type {
margin-bottom: 0;
}
.form-label {
display: block;
margin-bottom: 12rpx;
color: #374151;
font-size: 28rpx;
}
.picker-field {
display: block;
width: 100%;
}
.select,
.input,
.textarea {
box-sizing: border-box;
display: block;
width: 100%;
max-width: 100%;
}
.select {
display: flex;
align-items: center;
min-height: 84rpx;
padding: 0 24rpx;
border: 1px solid #d6dde8;
border-radius: 16rpx;
background: #fff;
font-size: 28rpx;
line-height: 1.4;
color: #111827;
}
.input {
height: 84rpx;
padding: 0 24rpx;
border: 1px solid #d6dde8;
border-radius: 16rpx;
background: #fff;
font-size: 28rpx;
}
.input[disabled] {
background: #f3f4f6;
color: #9ca3af;
}
3 weeks ago
.textarea {
min-height: 240rpx;
padding: 24rpx;
border: 1px solid #d6dde8;
border-radius: 16rpx;
background: #fff;
font-size: 28rpx;
line-height: 1.6;
}
.input-placeholder {
color: #9ca3af;
}
.hint {
display: block;
margin-top: 12rpx;
color: #6b7280;
font-size: 24rpx;
}
.submit-btn {
width: 100%;
min-height: 88rpx;
margin-top: 32rpx;
border-radius: 16rpx;
font-size: 30rpx;
font-weight: 500;
}
.submit-btn::after {
border: none;
}
.result-title {
display: block;
margin-bottom: 16rpx;
color: #111827;
font-size: 30rpx;
font-weight: 500;
}
.result-line {
display: block;
margin-top: 8rpx;
color: #4b5563;
font-size: 26rpx;
line-height: 1.5;
}
.hint-line {
color: #6b7280;
font-size: 24rpx;
}
3 weeks ago
.result-summary {
margin-top: 8rpx;
line-height: 1.6;
}
</style>