master
lion 2 weeks ago
parent d131c30932
commit c6d4ac0488

@ -0,0 +1,25 @@
import { request } from '@/utils/request'
export type CrawlAddressTargetType = 'paper' | 'industry_news' | 'teacher'
export interface CrawlAddressOption {
id: number
target_type: CrawlAddressTargetType
name: string
request_url: string
keyword?: string | null
category_dict_item_id?: number | null
category_label?: string | null
university_id?: number | null
university_name?: string | null
department?: string | null
}
export const crawlAddressApi = {
options(target_type?: CrawlAddressTargetType) {
const query = target_type ? `?target_type=${encodeURIComponent(target_type)}` : ''
return request<{ items: CrawlAddressOption[] }>(`/crawl-addresses/options${query}`, {
auth: true,
}).then((res) => res.items)
},
}

@ -7,6 +7,7 @@ export interface CrawlResolveResult {
source_name: string
adapter_code: string
target_type: string
entry_url?: string
}
export interface CrawlJobResult {
@ -16,10 +17,12 @@ export interface CrawlJobResult {
platform_url?: string
status: string
source_name?: string
adapter_code?: string
items_fetched?: number
items_imported?: number
papers_imported?: number
teacher_leads_imported?: number
news_imported?: number
result_summary?: string
preview_teacher_lead_count?: number
}
@ -36,6 +39,16 @@ export const crawlerApi = {
target_type: CrawlTargetType
request_url: string
params?: Record<string, unknown>
teacher_defaults?: {
university_id?: number
department?: string
city?: string
research_direction_ids?: number[]
}
news_defaults?: {
source?: string
category_dict_item_id?: number
}
}) {
return request<CrawlJobResult>('/crawl-jobs', {
method: 'POST',

@ -0,0 +1,61 @@
<template>
<view class="news-rich-content">
<block v-for="(block, index) in blocks" :key="`${index}-${block.type}`">
<rich-text v-if="block.type === 'html'" class="news-rich-text" :nodes="block.content" />
<image
v-else
class="news-rich-image"
:src="block.src"
mode="widthFix"
show-menu-by-longpress
@tap="previewImage(block.src)"
@error="onImageError(block.src)"
/>
</block>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { collectNewsImageUrls, splitNewsHtmlBlocks } from '@/utils/newsHtml'
const props = defineProps<{
html: string
}>()
const blocks = computed(() => splitNewsHtmlBlocks(props.html))
const imageUrls = computed(() => collectNewsImageUrls(props.html))
function previewImage(current: string) {
const urls = imageUrls.value
if (!urls.length) return
uni.previewImage({
current,
urls,
})
}
function onImageError(src: string) {
console.warn('[news-detail] image load failed:', src)
}
</script>
<style scoped lang="scss">
.news-rich-content {
width: 100%;
}
.news-rich-text {
display: block;
color: #273142;
font-size: 30rpx;
line-height: 1.9;
}
.news-rich-image {
display: block;
width: 100%;
max-width: 100%;
margin: 24rpx 0;
}
</style>

@ -70,8 +70,9 @@ export const useUserStore = defineStore('user', {
company: user.company || this.profile.company,
mobile: user.mobile || this.profile.mobile,
title: user.job_title || this.profile.title,
research_direction: user.research_direction || this.profile.research_direction,
research_direction_ids: user.research_direction_ids || this.profile.research_direction_ids,
custom_research_directions: this.profile.custom_research_directions,
custom_research_directions: [],
}
uni.setStorageSync(STORAGE_KEYS.profile, JSON.stringify(this.profile))
syncRadarTabPermission(user)
@ -86,8 +87,9 @@ export const useUserStore = defineStore('user', {
company: user.company || this.profile.company,
mobile: user.mobile || this.profile.mobile,
title: user.job_title || this.profile.title,
research_direction: user.research_direction || this.profile.research_direction,
research_direction_ids: user.research_direction_ids || this.profile.research_direction_ids,
custom_research_directions: this.profile.custom_research_directions,
custom_research_directions: [],
}
uni.setStorageSync(STORAGE_KEYS.profile, JSON.stringify(this.profile))
syncRadarTabPermission(user)
@ -115,8 +117,9 @@ export const useUserStore = defineStore('user', {
clearToken()
},
async saveProfile(profile: UserProfile) {
let user = this.user
if (this.isLoggedIn) {
const user = await profileApi.update({
user = await profileApi.update({
name: profile.name,
mobile: profile.mobile,
company: profile.company,
@ -128,7 +131,13 @@ export const useUserStore = defineStore('user', {
})
this.user = user
}
this.profile = { ...this.profile, ...profile }
this.profile = {
...this.profile,
...profile,
research_direction: user?.research_direction || this.profile.research_direction,
research_direction_ids: user?.research_direction_ids || profile.research_direction_ids,
custom_research_directions: [],
}
uni.setStorageSync(STORAGE_KEYS.profile, JSON.stringify(this.profile))
},
markCourseSigned(id: number) {

@ -8,6 +8,18 @@
</picker>
</view>
<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>
<view class="field">
<text class="form-label">目标地址 *</text>
<input
@ -18,39 +30,38 @@
@blur="onUrlBlur"
/>
<text v-if="resolving" class="hint"></text>
<text v-else-if="resolvedName" class="hint">已识别{{ resolvedName }}</text>
</view>
<view v-if="form.target_type !== 'teacher'" class="field">
<text class="form-label">搜索关键词选填</text>
<textarea
v-model="keyword"
class="textarea"
maxlength="500"
placeholder="多个关键词用逗号或换行分隔"
placeholder-class="input-placeholder"
/>
<text v-else-if="resolvedName" class="hint">已识别{{ resolvedName }}{{ resolvedAdapter ? `${formatAdapterLabel(resolvedAdapter)}` : '' }}</text>
<text v-if="selectedAddressHint" class="hint">{{ selectedAddressHint }}</text>
</view>
<view v-if="form.target_type === 'teacher'" class="field">
<view class="field">
<text class="form-label">搜索关键词选填</text>
<textarea
v-model="keyword"
class="textarea"
maxlength="500"
placeholder="多个关键词用空格、逗号或换行分隔"
:placeholder="keywordPlaceholder"
placeholder-class="input-placeholder"
/>
</view>
<view v-if="form.target_type === 'industry_news' || form.target_type === 'teacher'" class="field">
<view v-if="form.target_type === 'paper' || form.target_type === 'industry_news' || form.target_type === 'teacher'" class="field">
<text class="form-label">抓取页数</text>
<input v-model.number="maxPages" class="input" type="number" />
<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="form.target_type === 'teacher'" class="hint">多页列表 Sudy CMS博山 CMS交大 tsites请适当增大页数</text>
<text v-else-if="form.target_type === 'teacher'" class="hint">大批量抓取时仅部分老师会访问主页补邮箱避免请求超时</text>
</view>
<view class="field">
<text class="form-label">条数上限</text>
<input v-model.number="maxResults" class="input" type="number" />
<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-else-if="form.target_type === 'industry_news'" class="hint">资讯最多 50 URL 已入库将跳过不重写正文空正文需先删旧记录再重抓</text>
<text v-if="form.target_type === 'industry_news'" class="hint">使 HTML</text>
</view>
<button class="btn btn-primary submit-btn" :loading="submitting" @tap="submit"></button>
@ -60,8 +71,8 @@
<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>
<text v-if="lastResult.adapter_code" class="result-line">{{ formatAdapterLabel(lastResult.adapter_code) }}</text>
<text v-if="lastResult.result_summary" class="result-line">{{ lastResult.result_summary }}</text>
<text v-if="lastResult.result_summary" class="result-line result-summary">{{ lastResult.result_summary }}</text>
<text v-else class="result-line">已入库 {{ lastResult.items_imported ?? 0 }} </text>
<text v-if="lastResult.items_fetched" class="result-line"> {{ lastResult.items_fetched }} </text>
</view>
@ -78,9 +89,10 @@ export default {
</script>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { computed, reactive, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { crawlerApi, type CrawlTargetType } from '@/api/crawler'
import { crawlAddressApi, type CrawlAddressOption } from '@/api/crawl-addresses'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
@ -92,18 +104,156 @@ const typeOptions: { label: string; value: CrawlTargetType }[] = [
const typeLabels = typeOptions.map((item) => item.label)
const typeIndex = ref(0)
const keyword = ref('')
const maxPages = ref(5)
const maxResults = ref(20)
const maxPages = ref(1)
const maxResults = ref(50)
const resolving = ref(false)
const submitting = ref(false)
const resolvedName = ref('')
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',
}
function formatAdapterLabel(code: string) {
return ADAPTER_LABELS[code] || code
}
const lastResult = ref<Awaited<ReturnType<typeof crawlerApi.submit>> | null>(null)
const addressOptions = ref<CrawlAddressOption[]>([])
const addressIndex = ref(0)
const crawlDefaults = ref<{
category_dict_item_id?: number
category_label?: string
source_name?: string
university_id?: number
university_name?: string
department?: string
}>({})
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}`)
}
return parts.length > 0 ? parts.join('') : ''
})
const form = reactive({
target_type: 'paper' as CrawlTargetType,
request_url: '',
request_url: 'https://arxiv.org/',
})
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
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
}
}
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
crawlDefaults.value = {}
return
}
addressIndex.value = addressOptions.value.indexOf(matched) + 1
if (options?.fillKeyword && matched.keyword) {
keyword.value = matched.keyword
}
applyCrawlDefaults(matched)
}
onShow(async () => {
if (!userStore.isLoggedIn) {
uni.navigateTo({ url: '/subpkg/login/index' })
@ -117,6 +267,12 @@ onShow(async () => {
if (!userStore.isStaff) {
uni.showToast({ title: '无权使用数据爬虫', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1200)
return
}
applyTypeDefaults(form.target_type)
await loadAddresses()
if (form.request_url.trim()) {
void onUrlBlur()
}
})
@ -124,30 +280,61 @@ function onTypeChange(event: UniHelper.PickerChangeEvent) {
typeIndex.value = Number(event.detail.value)
form.target_type = typeOptions[typeIndex.value]?.value || 'paper'
resolvedName.value = ''
if (form.target_type === 'teacher') {
maxResults.value = 100
} else if (form.target_type === 'industry_news') {
maxResults.value = 30
} else {
maxResults.value = 20
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) {
crawlDefaults.value = {}
return
}
const addr = addressOptions.value[addressIndex.value - 1]
if (!addr) return
form.request_url = addr.request_url
if (addr.keyword) {
keyword.value = addr.keyword
}
applyCrawlDefaults(addr)
void onUrlBlur()
}
async function onUrlBlur() {
const url = form.request_url.trim()
if (!url) {
resolvedName.value = ''
resolvedAdapter.value = ''
resolvedUrl.value = ''
addressIndex.value = 0
crawlDefaults.value = {}
return
}
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 })
resolving.value = true
try {
const res = await crawlerApi.resolveUrl({
request_url: url.startsWith('http') ? url : `https://${url}`,
request_url: normalized,
target_type: form.target_type,
})
resolvedName.value = res.source_name
resolvedAdapter.value = res.adapter_code || ''
resolvedUrl.value = normalized
} catch (error) {
resolvedName.value = ''
resolvedAdapter.value = ''
resolvedUrl.value = ''
uni.showToast({
title: error instanceof Error ? error.message : '无法识别该地址',
icon: 'none',
@ -157,40 +344,109 @@ async function onUrlBlur() {
}
}
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
}
function buildParams(): Record<string, unknown> {
clampParams(form.target_type)
const params: Record<string, unknown> = {}
if (keyword.value.trim()) {
params.keyword = keyword.value.trim()
}
params.keyword = keyword.value.trim()
params.max_results = maxResults.value
if (form.target_type === 'industry_news' || form.target_type === 'teacher') {
if (form.target_type === 'paper' || form.target_type === 'industry_news' || form.target_type === 'teacher') {
params.max_pages = maxPages.value
}
return params
}
function buildSuccessMessage(result: Awaited<ReturnType<typeof crawlerApi.submit>>): string {
if (result.result_summary) {
return result.result_summary
}
if (result.target_type === 'paper') {
const papers = result.papers_imported ?? result.items_imported ?? 0
const leads = result.teacher_leads_imported ?? 0
return `已入库 ${papers} 篇论文、${leads} 位作者`
}
if (result.target_type === 'industry_news') {
const news = result.items_imported ?? 0
return `已入库 ${news} 条资讯`
}
if (result.target_type === 'teacher') {
const teachers = result.items_imported ?? 0
return `已入库 ${teachers} 位老师`
}
return `已入库 ${result.items_imported ?? 0}`
}
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
}
async function submit() {
const url = form.request_url.trim()
if (!url) {
uni.showToast({ title: '请填写目标地址', icon: 'none' })
if (!(await ensureResolved())) {
uni.showToast({ title: '无法识别该地址,请检查入库类型与 URL', icon: 'none' })
return
}
submitting.value = true
try {
lastResult.value = await crawlerApi.submit({
syncFromCrawlAddress(form.request_url, { fillKeyword: false })
const normalizedUrl = normalizeUrl(form.request_url)
const payload: Parameters<typeof crawlerApi.submit>[0] = {
target_type: form.target_type,
request_url: url.startsWith('http') ? url : `https://${url}`,
request_url: normalizedUrl,
params: buildParams(),
})
}
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)
uni.showToast({
title: buildSuccessMessage(lastResult.value),
icon: 'success',

@ -4,7 +4,10 @@
<text class="news-detail-title">{{ item.title }}</text>
<text class="news-detail-date">{{ item.date }}</text>
<text v-if="item.source" class="news-detail-source">{{ item.source }}</text>
<text v-if="!showSourceLinkAction" class="news-detail-content">{{ item.content }}</text>
<view v-if="showRichContent" class="news-detail-content">
<NewsRichContent :html="item.contentHtml || ''" />
</view>
<text v-else-if="!showSourceLinkAction" class="news-detail-content-text">{{ item.content }}</text>
<view v-else class="news-empty-content">
<text class="news-empty-tip">暂无正文内容可复制原文链接查看</text>
<button
@ -42,17 +45,22 @@ import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { newsApi } from '@/api/news'
import PrivacyAgreementModal from '@/components/PrivacyAgreementModal.vue'
import NewsRichContent from '@/components/NewsRichContent.vue'
import type { NewsItem } from '@/types'
import { usePrivacyModal } from '@/composables/usePrivacyModal'
import { useShare } from '@/composables/useShare'
import { adaptNews, isNewsContentMissing } from '@/utils/adapters'
import { adaptNews, hasNewsHtmlContent, isNewsContentMissing } from '@/utils/adapters'
import { copyText } from '@/utils/clipboard'
const item = ref<NewsItem | null>(null)
const loading = ref(false)
const { privacyVisible, privacyContractName, onPrivacyConfirm, onPrivacyCancel } = usePrivacyModal()
const showSourceLinkAction = computed(() => item.value && isNewsContentMissing(item.value.content))
const showRichContent = computed(() => item.value && hasNewsHtmlContent(item.value.contentHtml))
const showSourceLinkAction = computed(
() => item.value && !showRichContent.value && isNewsContentMissing(item.value.content, item.value.contentHtml),
)
useShare(() => ({
title: item.value?.title || undefined,
@ -113,6 +121,14 @@ function copySourceUrl() {
}
.news-detail-content {
margin-top: 36rpx;
overflow: hidden;
color: #273142;
font-size: 30rpx;
line-height: 1.8;
}
.news-detail-content-text {
display: block;
margin-top: 36rpx;
color: #273142;

@ -100,12 +100,14 @@ const form = reactive({
const avatarPreview = computed(() => form.avatar || userStore.avatarSrc)
const selectedDirectionText = computed(() => {
if (!form.research_direction_ids.length) return ''
const nameMap = Object.fromEntries(directionOptions.value.map((item) => [item.id, item.name]))
const selected = form.research_direction_ids
.map((id) => nameMap[id])
.filter(Boolean)
return [...selected, ...form.custom_research_directions].join('、')
const custom = form.custom_research_directions.map((name) => name.trim()).filter(Boolean)
const combined = [...selected, ...custom]
if (combined.length) return combined.join('、')
return userStore.user?.research_direction || ''
})
onMounted(async () => {
@ -118,10 +120,6 @@ onMounted(async () => {
})
function openDirectionPicker() {
if (!directionOptions.value.length) {
uni.showToast({ title: '暂无可选研究方向', icon: 'none' })
return
}
directionPickerVisible.value = true
}

@ -79,6 +79,7 @@ export interface NewsItem {
sourceUrl?: string
summary: string
content: string
contentHtml?: string
}
export interface DemandItem {
@ -105,6 +106,7 @@ export interface UserProfile {
mobile?: string
company?: string
title?: string
research_direction?: string
research_direction_ids?: number[]
custom_research_directions?: string[]
}

@ -1,5 +1,6 @@
import type { ApiActivity, ApiActivitySession, ApiCourse, ApiNews } from '@/types/api'
import type { ActivityItem, CourseItem, CourseSession, CourseSpeaker } from '@/types'
import { hasNewsHtmlContent, normalizeNewsHtml } from '@/utils/newsHtml'
function adaptCourseSpeakers(api: ApiCourse): CourseSpeaker[] {
const speakers = api.main_speakers?.length
@ -110,6 +111,7 @@ export function adaptActivitySession(session: ApiActivitySession): CourseSession
}
export function adaptNews(api: ApiNews) {
const contentHtml = normalizeNewsHtml(api.content_html || '')
return {
id: api.id,
title: api.title,
@ -118,13 +120,20 @@ export function adaptNews(api: ApiNews) {
source: api.source || undefined,
sourceUrl: api.source_url || undefined,
summary: api.summary || '',
content: api.content || stripHtml(api.content_html || ''),
content: api.content || stripHtml(contentHtml),
contentHtml,
}
}
const CRAWLER_PLACEHOLDER_CONTENT = '(爬虫抓取,请编辑正文)'
export function isNewsContentMissing(content?: string | null): boolean {
export { hasNewsHtmlContent } from '@/utils/newsHtml'
export function isNewsContentMissing(content?: string | null, contentHtml?: string | null): boolean {
if (hasNewsHtmlContent(contentHtml)) {
return false
}
const text = stripHtml(content || '').replace(/\s+/g, '')
if (!text) return true
return text === CRAWLER_PLACEHOLDER_CONTENT || text === '爬虫抓取,请编辑正文'

@ -0,0 +1,77 @@
import { API_BASE } from '@/config'
export type NewsHtmlBlock =
| { type: 'html'; content: string }
| { type: 'image'; src: string }
export function normalizeNewsHtml(html: string): string {
const trimmed = html.trim()
if (!trimmed) return ''
const siteBase = API_BASE.replace(/\/api\/miniapp\/v1\/?$/, '').replace(/\/$/, '')
return repairBrokenImgTags(trimmed)
.replace(/src=(["'])(\/storage\/[^"']+)\1/gi, (_, quote, path) => `src=${quote}${siteBase}${path}${quote}`)
.replace(/src=(["'])(storage\/[^"']+)\1/gi, (_, quote, path) => `src=${quote}${siteBase}/${path}${quote}`)
}
/** 修复 src 缺少闭合引号等常见脏 HTML */
export function repairBrokenImgTags(html: string): string {
return html
.replace(/(<img\b[^>]*?\ssrc=")(https?:\/\/[^\s">]+)(?=>)/gi, '$1$2"')
.replace(/(<img\b[^>]*?\ssrc=')(https?:\/\/[^\s'>]+)(?=>)/gi, "$1$2'")
.replace(/(<img\b[^>]*?\ssrc=")(https?:\/\/[^">\s]+)(?=\s[^>]*>)/gi, '$1$2"')
.replace(/(<img\b[^>]*?\ssrc=')(https?:\/\/[^'>\s]+)(?=\s[^>]*>)/gi, "$1$2'")
}
export function splitNewsHtmlBlocks(html: string): NewsHtmlBlock[] {
const normalized = normalizeNewsHtml(html)
if (!normalized) return []
const blocks: NewsHtmlBlock[] = []
const regex = /<img\b[^>]*?\ssrc=(["'])(.*?)\1[^>]*>/gi
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = regex.exec(normalized)) !== null) {
const htmlChunk = normalized.slice(lastIndex, match.index).trim()
if (htmlChunk) {
blocks.push({ type: 'html', content: htmlChunk })
}
const src = match[2]?.trim()
if (src) {
blocks.push({ type: 'image', src })
}
lastIndex = regex.lastIndex
}
const tail = normalized.slice(lastIndex).trim()
if (tail) {
blocks.push({ type: 'html', content: tail })
}
return blocks
}
export function collectNewsImageUrls(html: string): string[] {
return splitNewsHtmlBlocks(html)
.filter((block): block is Extract<NewsHtmlBlock, { type: 'image' }> => block.type === 'image')
.map((block) => block.src)
}
export function hasNewsHtmlContent(contentHtml?: string | null): boolean {
const html = normalizeNewsHtml(contentHtml || '')
if (!html) return false
if (/<img\b/i.test(html)) return true
const text = stripHtml(html).replace(/\s+/g, '')
if (!text) return false
if (text === '(爬虫抓取,请编辑正文)'.replace(/\s+/g, '')) return false
if (text === '爬虫抓取,请编辑正文') return false
return text.length >= 10
}
function stripHtml(value: string) {
return value.replace(/<[^>]+>/g, '').trim()
}
Loading…
Cancel
Save