Files
planet/backend/app/services/earth_news_manual.py
linkong 899e3bce43
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled
release: bump version to 0.71.0
2026-06-11 16:47:24 +08:00

694 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import hashlib
import html
import json
import re
from typing import Any
from bs4 import BeautifulSoup
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.enums import NewsEnrichmentStatus, NewsSourceType, NewsTaggingSource
from app.core.websocket.broadcaster import broadcaster
from app.models.earth_news import EarthNewsItem
from app.models.system_setting import SystemSetting
from app.services.earth_news import (
ALLOWED_NEWS_CATEGORY_KEYS,
DEFAULT_NEWS_LOCALE,
REGION_ANCHORS,
NewsFeedEndpoint,
NewsFeedSource,
NewsTargetLocation,
ParsedNewsItem,
apply_news_classification,
build_anchor_location_patch,
build_target_location_job_payload,
build_target_location_patch,
_serialize_item,
)
from app.services.earth_news_queue import enqueue_target_location_job
from app.services.earth_news_store import record_to_parsed_news_item
MANUAL_NEWS_SOURCE_ID = "manual"
MANUAL_NEWS_SOURCE_LABEL = "手动添加"
MANUAL_NEWS_MAX_IMPORT_ITEMS = 500
MANUAL_NEWS_MAX_TITLE_LENGTH = 500
MANUAL_NEWS_MAX_SUMMARY_LENGTH = 1200
MANUAL_NEWS_MAX_CONTENT_LENGTH = 12000
EARTH_NEWS_MANUAL_GROUPS_CATEGORY = "earth_news_manual_groups"
DEFAULT_MANUAL_NEWS_GROUP_ID = "manual-default"
DEFAULT_MANUAL_NEWS_GROUP_NAME = "新建新闻组"
@dataclass(frozen=True)
class ManualNewsWriteResult:
item: EarthNewsItem
created: bool
queued: bool
@dataclass(frozen=True)
class ManualNewsGroup:
id: str
name: str
sort_order: int = 0
def _clean_text(value: object, *, max_length: int) -> str:
raw = "" if value is None else str(value)
text = BeautifulSoup(html.unescape(raw), "html.parser").get_text(" ", strip=True)
text = re.sub(r"\s+", " ", text).strip()
if len(text) > max_length:
return text[: max_length - 1].rstrip() + ""
return text
def _parse_datetime(value: object) -> datetime | None:
if value is None or str(value).strip() == "":
return None
if isinstance(value, datetime):
parsed = value
else:
try:
parsed = datetime.fromisoformat(str(value).strip().replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError("published_at 必须是 ISO8601 时间。") from exc
if parsed.tzinfo is None:
return parsed.replace(tzinfo=UTC)
return parsed.astimezone(UTC)
def _detect_language(*parts: str) -> str:
text = " ".join(part for part in parts if part)
cjk_count = len(re.findall(r"[\u4e00-\u9fff]", text))
latin_count = len(re.findall(r"[A-Za-z]", text))
return "zh-CN" if cjk_count >= max(4, latin_count // 3) else "en-US"
def _manual_item_id(*, title: str, published_at: datetime | None, url: str, source: str) -> str:
published = published_at.isoformat() if published_at else ""
basis = "\n".join([title.strip().lower(), published, url.strip().lower(), source.strip().lower()])
return f"manual:{hashlib.sha1(basis.encode('utf-8')).hexdigest()[:16]}"
def _manual_group_id(name: str) -> str:
basis = f"{name.strip().lower()}\n{datetime.now(UTC).isoformat()}"
return f"manual-group:{hashlib.sha1(basis.encode('utf-8')).hexdigest()[:10]}"
def _news_meta(record: EarthNewsItem) -> dict[str, Any]:
location_meta = record.location_meta if isinstance(record.location_meta, dict) else {}
news_meta = location_meta.get("news_meta")
return dict(news_meta) if isinstance(news_meta, dict) else {}
def _record_source_type(record: EarthNewsItem) -> str:
return str(_news_meta(record).get("feed_type") or _news_meta(record).get("source_type") or "rss")
def _record_manual_group_id(record: EarthNewsItem) -> str:
return str(_news_meta(record).get("manual_group_id") or DEFAULT_MANUAL_NEWS_GROUP_ID)
def _rss_group_id(record: EarthNewsItem) -> str:
basis = "\n".join(
[
_record_source_type(record),
str(record.feed_name or ""),
str(record.source or ""),
]
)
return f"rss:{hashlib.sha1(basis.encode('utf-8')).hexdigest()[:12]}"
def _default_manual_group() -> dict[str, Any]:
return {
"id": DEFAULT_MANUAL_NEWS_GROUP_ID,
"name": DEFAULT_MANUAL_NEWS_GROUP_NAME,
"sort_order": 0,
}
def _normalize_manual_groups_payload(payload: Any) -> list[dict[str, Any]]:
raw_groups = payload.get("groups") if isinstance(payload, dict) else None
normalized: list[dict[str, Any]] = []
seen: set[str] = set()
for index, item in enumerate(raw_groups if isinstance(raw_groups, list) else []):
if not isinstance(item, dict):
continue
group_id = str(item.get("id") or "").strip()
name = _clean_text(item.get("name"), max_length=120)
if not group_id or not name or group_id in seen:
continue
normalized.append(
{
"id": group_id,
"name": name,
"sort_order": int(item.get("sort_order") or index),
}
)
seen.add(group_id)
if DEFAULT_MANUAL_NEWS_GROUP_ID not in seen:
normalized.insert(0, _default_manual_group())
return sorted(normalized, key=lambda item: (int(item.get("sort_order") or 0), str(item.get("name") or "")))
async def _get_manual_groups_record(db: AsyncSession) -> SystemSetting | None:
result = await db.execute(
select(SystemSetting).where(SystemSetting.category == EARTH_NEWS_MANUAL_GROUPS_CATEGORY)
)
return result.scalar_one_or_none()
async def get_manual_news_groups(db: AsyncSession) -> list[dict[str, Any]]:
record = await _get_manual_groups_record(db)
return _normalize_manual_groups_payload(record.payload if record else None)
async def _save_manual_news_groups(db: AsyncSession, groups: list[dict[str, Any]]) -> list[dict[str, Any]]:
normalized = _normalize_manual_groups_payload({"groups": groups})
record = await _get_manual_groups_record(db)
payload = {"groups": normalized}
if record is None:
db.add(SystemSetting(category=EARTH_NEWS_MANUAL_GROUPS_CATEGORY, payload=payload))
else:
record.payload = payload
await db.flush()
return normalized
async def resolve_manual_news_group(db: AsyncSession, group_id: str | None) -> ManualNewsGroup:
normalized_id = str(group_id or DEFAULT_MANUAL_NEWS_GROUP_ID).strip() or DEFAULT_MANUAL_NEWS_GROUP_ID
groups = await get_manual_news_groups(db)
match = next((item for item in groups if item.get("id") == normalized_id), None)
if match is None and normalized_id != DEFAULT_MANUAL_NEWS_GROUP_ID:
raise ValueError(f"手动新闻组不存在:{normalized_id}")
match = match or _default_manual_group()
return ManualNewsGroup(
id=str(match["id"]),
name=str(match["name"]),
sort_order=int(match.get("sort_order") or 0),
)
async def create_manual_news_group(db: AsyncSession, name: str) -> dict[str, Any]:
group_name = _clean_text(name, max_length=120)
if not group_name:
raise ValueError("新闻组名称不能为空。")
groups = await get_manual_news_groups(db)
group = {"id": _manual_group_id(group_name), "name": group_name, "sort_order": len(groups)}
groups.append(group)
await _save_manual_news_groups(db, groups)
return group
async def rename_manual_news_group(db: AsyncSession, group_id: str, name: str) -> dict[str, Any]:
group_name = _clean_text(name, max_length=120)
if not group_name:
raise ValueError("新闻组名称不能为空。")
groups = await get_manual_news_groups(db)
match = next((item for item in groups if item.get("id") == group_id), None)
if match is None:
raise ValueError(f"手动新闻组不存在:{group_id}")
match["name"] = group_name
await _save_manual_news_groups(db, groups)
result = await db.execute(
select(EarthNewsItem).where(
EarthNewsItem.location_meta.op("->")("news_meta").op("->>")("feed_type") == NewsSourceType.MANUAL.value
)
)
for record in result.scalars().all():
if _record_manual_group_id(record) != group_id:
continue
location_meta = dict(record.location_meta or {})
news_meta = dict(location_meta.get("news_meta") or {})
news_meta["manual_group_name"] = group_name
location_meta["news_meta"] = news_meta
record.location_meta = location_meta
await db.flush()
return match
def _normalize_region(value: object) -> str:
region = str(value or "global").strip().lower() or "global"
if region not in REGION_ANCHORS:
raise ValueError(f"region 不支持:{region}")
return region
def _normalize_tags(value: object) -> list[str]:
if value is None:
return []
if isinstance(value, str):
parts = re.split(r"[,\n]", value)
elif isinstance(value, list):
parts = [str(item) for item in value]
else:
raise ValueError("tags 必须是字符串数组或逗号分隔字符串。")
return [item.strip() for item in parts if item.strip()][:20]
def _normalize_location(value: object) -> NewsTargetLocation | None:
if value in (None, ""):
return None
if not isinstance(value, dict):
raise ValueError("location 必须是对象。")
lat = value.get("latitude")
lon = value.get("longitude")
if lat in (None, "") and lon in (None, ""):
return None
try:
latitude = float(lat)
longitude = float(lon)
except (TypeError, ValueError) as exc:
raise ValueError("location.latitude / longitude 必须是数字。") from exc
if not -90 <= latitude <= 90 or not -180 <= longitude <= 180:
raise ValueError("location 经纬度超出范围。")
label = _clean_text(value.get("label"), max_length=255)
if not label:
label = f"{latitude:.4f}, {longitude:.4f}"
return NewsTargetLocation(
latitude=latitude,
longitude=longitude,
label=label,
source="manual_location",
confidence=1.0,
country=_clean_text(value.get("country"), max_length=100) or None,
city=_clean_text(value.get("city"), max_length=100) or None,
)
def _manual_source(source_name: str, *, region: str) -> NewsFeedSource:
return NewsFeedSource(
id=MANUAL_NEWS_SOURCE_ID,
name=source_name or MANUAL_NEWS_SOURCE_LABEL,
region=region,
feed_url="",
homepage_url="",
source_type=NewsSourceType.MANUAL.value,
default_category="other",
source_tags=("manual",),
)
def _manual_feed(category: str) -> NewsFeedEndpoint:
return NewsFeedEndpoint(
id=MANUAL_NEWS_SOURCE_ID,
name=MANUAL_NEWS_SOURCE_LABEL,
url="",
type=NewsSourceType.MANUAL.value,
default_category=category or "other",
tags=("manual",),
priority=1,
)
def parsed_manual_news_item(
payload: dict[str, Any],
*,
item_id_override: str | None = None,
) -> tuple[ParsedNewsItem, NewsTargetLocation | None, str]:
title = _clean_text(payload.get("title"), max_length=MANUAL_NEWS_MAX_TITLE_LENGTH)
if not title:
raise ValueError("title 不能为空。")
content = _clean_text(payload.get("content"), max_length=MANUAL_NEWS_MAX_CONTENT_LENGTH)
summary = _clean_text(payload.get("summary"), max_length=MANUAL_NEWS_MAX_SUMMARY_LENGTH)
if not summary:
summary = _clean_text(content, max_length=240) if content else title
source = _clean_text(payload.get("source"), max_length=255) or MANUAL_NEWS_SOURCE_LABEL
region = _normalize_region(payload.get("region"))
published_at = _parse_datetime(payload.get("published_at")) or datetime.now(UTC)
url = str(payload.get("url") or "").strip()
category = str(payload.get("category") or "other").strip().lower() or "other"
if category not in ALLOWED_NEWS_CATEGORY_KEYS:
raise ValueError(f"category 不支持:{category}")
tags = _normalize_tags(payload.get("tags"))
target = _normalize_location(payload.get("location"))
language = str(payload.get("content_language") or "").strip() or _detect_language(title, summary, content)
localizations = {
language: {
"title": title,
"summary": summary,
}
}
item = ParsedNewsItem(
id=item_id_override
or _manual_item_id(title=title, published_at=published_at, url=url, source=source),
title=title,
summary=summary,
url=url,
source=source,
feed_name=MANUAL_NEWS_SOURCE_LABEL,
feed_region=region,
homepage_url=str(payload.get("homepage_url") or ""),
published_at=published_at,
content_language=language,
localizations=localizations,
enrichment_status=NewsEnrichmentStatus.PENDING.value,
source_tags=["manual"],
feed_id=MANUAL_NEWS_SOURCE_ID,
feed_type=NewsSourceType.MANUAL.value,
feed_default_category=category,
category=category,
item_tags=tags,
tagging_source=NewsTaggingSource.MANUAL.value if payload.get("category") else NewsTaggingSource.RULES.value,
tagging_confidence=0.9 if payload.get("category") else 0.0,
)
source_config = _manual_source(source, region=region)
feed = _manual_feed(category)
apply_news_classification(item, source_config, feed=feed)
if payload.get("category"):
item.category = category
item.tagging_source = NewsTaggingSource.MANUAL.value
item.tagging_confidence = 0.9
if tags:
item.item_tags = sorted(set([*item.item_tags, *tags]))
return item, target, content
def _manual_editable(record: EarthNewsItem) -> bool:
if record.id.startswith("manual:"):
return True
news_meta = (record.location_meta or {}).get("news_meta") if isinstance(record.location_meta, dict) else None
return isinstance(news_meta, dict) and news_meta.get("feed_type") == NewsSourceType.MANUAL.value
async def _broadcast_news_reload() -> None:
await broadcaster.broadcast_earth_update(
{
"action": "database_changed",
"source": "earth_news_items",
"layers": ["news"],
"refresh_strategy": "reload",
}
)
async def upsert_manual_news_item(
db: AsyncSession,
payload: dict[str, Any],
*,
item_id_override: str | None = None,
group_id: str | None = None,
) -> ManualNewsWriteResult:
item, target, content = parsed_manual_news_item(payload, item_id_override=item_id_override)
group = await resolve_manual_news_group(db, group_id or payload.get("group_id"))
existing = await db.get(EarthNewsItem, item.id)
created = existing is None
patch = build_target_location_patch(item, target) if target else build_anchor_location_patch(item)
patch_meta = dict(patch.get("location_meta") or {})
patch_news_meta = dict(patch_meta.get("news_meta") or {})
patch_news_meta["feed_type"] = NewsSourceType.MANUAL.value
patch_news_meta["source_type"] = NewsSourceType.MANUAL.value
patch_news_meta["manual_group_id"] = group.id
patch_news_meta["manual_group_name"] = group.name
patch_meta["news_meta"] = patch_news_meta
patch["location_meta"] = patch_meta
now = datetime.now(UTC)
record = existing or EarthNewsItem(
id=item.id,
title=item.title,
summary=item.summary,
content_language=item.content_language,
localizations=dict(item.localizations or {}),
url=item.url,
source=item.source,
feed_name=item.feed_name,
region=item.feed_region,
homepage_url=item.homepage_url,
published_at=item.published_at,
latitude=patch["latitude"],
longitude=patch["longitude"],
location_label=patch["location_label"],
location_source=patch["location_source"],
verified=patch["verified"],
location_meta=patch["location_meta"],
first_seen_at=now,
last_seen_at=now,
resolved_at=now if patch["verified"] else None,
enrichment_status=item.enrichment_status,
)
if existing is None:
db.add(record)
else:
if not _manual_editable(record):
raise PermissionError("RSS 新闻不允许通过手动新闻接口编辑。")
record.title = item.title
record.summary = item.summary
record.content_language = item.content_language
record.localizations = dict(item.localizations or {})
record.url = item.url
record.source = item.source
record.feed_name = item.feed_name
record.region = item.feed_region
record.homepage_url = item.homepage_url
record.published_at = item.published_at
record.last_seen_at = now
if target is None and record.location_source == "manual_location":
merged_meta = dict(record.location_meta or {})
patch_meta = patch.get("location_meta") if isinstance(patch, dict) else None
patch_news_meta = patch_meta.get("news_meta") if isinstance(patch_meta, dict) else None
if isinstance(patch_news_meta, dict):
merged_meta["news_meta"] = patch_news_meta
record.location_meta = merged_meta
else:
record.location_meta = patch["location_meta"]
if target:
record.latitude = patch["latitude"]
record.longitude = patch["longitude"]
record.location_label = patch["location_label"]
record.location_source = patch["location_source"]
record.verified = patch["verified"]
record.resolved_at = now
elif record.location_source != "manual_location":
record.latitude = patch["latitude"]
record.longitude = patch["longitude"]
record.location_label = patch["location_label"]
record.location_source = patch["location_source"]
record.verified = patch["verified"]
record.resolved_at = None
record.enrichment_status = NewsEnrichmentStatus.PENDING.value
record.enrichment_error = None
record.enriched_at = None
if content:
meta = dict(record.location_meta or {})
meta["manual_content"] = content
record.location_meta = meta
await db.flush()
queued = await enqueue_target_location_job(build_target_location_job_payload(item), force=True)
if queued:
record.enrichment_status = NewsEnrichmentStatus.QUEUED.value
await db.flush()
return ManualNewsWriteResult(item=record, created=created, queued=queued)
async def import_manual_news_items(
db: AsyncSession,
payload: list[Any],
*,
group_id: str | None = None,
) -> dict[str, Any]:
if len(payload) > MANUAL_NEWS_MAX_IMPORT_ITEMS:
raise ValueError(f"单次最多导入 {MANUAL_NEWS_MAX_IMPORT_ITEMS} 条。")
created = 0
updated = 0
queued = 0
errors: list[dict[str, Any]] = []
for index, raw_item in enumerate(payload):
if not isinstance(raw_item, dict):
errors.append({"index": index, "error": "条目必须是 JSON 对象。"})
continue
try:
result = await upsert_manual_news_item(db, raw_item, group_id=group_id)
created += 1 if result.created else 0
updated += 0 if result.created else 1
queued += 1 if result.queued else 0
except Exception as exc:
errors.append({"index": index, "error": str(exc)})
if errors and created == 0 and updated == 0:
raise ValueError("导入失败,未写入任何新闻。")
return {"created": created, "updated": updated, "queued": queued, "failed": len(errors), "errors": errors}
async def parse_manual_news_import_upload(raw_bytes: bytes) -> list[Any]:
try:
payload = json.loads(raw_bytes.decode("utf-8-sig"))
except UnicodeDecodeError as exc:
raise ValueError("JSON 文件必须使用 UTF-8 编码。") from exc
except json.JSONDecodeError as exc:
raise ValueError(f"JSON 解析失败:第 {exc.lineno} 行第 {exc.colno} 列。") from exc
if not isinstance(payload, list):
raise ValueError("JSON 顶层必须是数组。")
return payload
def serialize_news_record(record: EarthNewsItem, *, locale: str = DEFAULT_NEWS_LOCALE) -> dict[str, Any]:
item = record_to_parsed_news_item(record)
payload = _serialize_item(item, active_region=item.feed_region, locale=locale)
news_meta = _news_meta(record)
payload["editable"] = _manual_editable(record)
payload["source_type"] = payload.get("feed_type")
payload["status"] = record.enrichment_status
payload["translated"] = bool((record.localizations or {}).get("zh-CN") and (record.localizations or {}).get("en-US"))
payload["manual_content"] = (record.location_meta or {}).get("manual_content") if isinstance(record.location_meta, dict) else None
payload["manual_group_id"] = news_meta.get("manual_group_id")
payload["manual_group_name"] = news_meta.get("manual_group_name")
return payload
def _record_matches_group(record: EarthNewsItem, group_id: str) -> bool:
source_type = _record_source_type(record)
if source_type == NewsSourceType.MANUAL.value:
return _record_manual_group_id(record) == group_id
return _rss_group_id(record) == group_id
async def list_news_records(
db: AsyncSession,
*,
page: int,
page_size: int,
source_type: str | None = None,
region: str | None = None,
category: str | None = None,
status_filter: str | None = None,
group_id: str | None = None,
) -> dict[str, Any]:
page = max(page, 1)
page_size = min(max(page_size, 1), 100)
query = select(EarthNewsItem)
count_query = select(func.count(EarthNewsItem.id))
filters = []
if region and region != "all":
filters.append(EarthNewsItem.region == region)
if status_filter and status_filter != "all":
filters.append(EarthNewsItem.enrichment_status == status_filter)
if source_type and source_type != "all":
filters.append(EarthNewsItem.location_meta.op("->")("news_meta").op("->>")("feed_type") == source_type)
if category and category != "all":
filters.append(EarthNewsItem.location_meta.op("->")("news_meta").op("->>")("category") == category)
for clause in filters:
query = query.where(clause)
count_query = count_query.where(clause)
ordered_query = query.order_by(EarthNewsItem.published_at.desc().nullslast(), EarthNewsItem.last_seen_at.desc())
if group_id:
result = await db.execute(ordered_query)
all_records = [record for record in result.scalars().all() if _record_matches_group(record, group_id)]
total = len(all_records)
records = all_records[(page - 1) * page_size : page * page_size]
else:
total_result = await db.execute(count_query)
result = await db.execute(
ordered_query.offset((page - 1) * page_size).limit(page_size)
)
records = list(result.scalars().all())
total = int(total_result.scalar() or 0)
return {
"items": [serialize_news_record(record) for record in records],
"page": page,
"page_size": page_size,
"total": total,
}
async def list_news_groups(db: AsyncSession, *, locale: str = DEFAULT_NEWS_LOCALE) -> dict[str, Any]:
manual_groups = await get_manual_news_groups(db)
manual_by_id: dict[str, dict[str, Any]] = {
str(group["id"]): {
"id": str(group["id"]),
"name": str(group["name"]),
"group_type": "manual",
"source_type": NewsSourceType.MANUAL.value,
"editable": True,
"sort_order": int(group.get("sort_order") or 0),
"count": 0,
"items": [],
}
for group in manual_groups
}
rss_by_id: dict[str, dict[str, Any]] = {}
result = await db.execute(
select(EarthNewsItem).order_by(EarthNewsItem.published_at.desc().nullslast(), EarthNewsItem.last_seen_at.desc())
)
for record in result.scalars().all():
serialized = serialize_news_record(record, locale=locale)
source_type = _record_source_type(record)
if source_type == NewsSourceType.MANUAL.value:
group_id = _record_manual_group_id(record)
group = manual_by_id.setdefault(
group_id,
{
"id": group_id,
"name": str(_news_meta(record).get("manual_group_name") or DEFAULT_MANUAL_NEWS_GROUP_NAME),
"group_type": "manual",
"source_type": NewsSourceType.MANUAL.value,
"editable": True,
"sort_order": len(manual_by_id),
"count": 0,
"items": [],
},
)
else:
group_id = _rss_group_id(record)
group = rss_by_id.setdefault(
group_id,
{
"id": group_id,
"name": record.feed_name or record.source or "RSS 新闻",
"group_type": "rss",
"source_type": source_type,
"editable": False,
"region": record.region,
"source": record.source,
"feed_name": record.feed_name,
"count": 0,
"items": [],
},
)
group["count"] = int(group.get("count") or 0) + 1
group.setdefault("items", []).append(serialized)
manual_items = sorted(manual_by_id.values(), key=lambda item: (int(item.get("sort_order") or 0), str(item.get("name") or "")))
rss_items = sorted(rss_by_id.values(), key=lambda item: str(item.get("name") or ""))
return {"groups": [*manual_items, *rss_items], "manual_groups": manual_items, "rss_groups": rss_items}
async def get_news_record_or_404(db: AsyncSession, item_id: str) -> EarthNewsItem | None:
return await db.get(EarthNewsItem, item_id)
async def delete_manual_news_item(db: AsyncSession, item_id: str) -> bool:
record = await db.get(EarthNewsItem, item_id)
if record is None:
return False
if not _manual_editable(record):
raise PermissionError("RSS 新闻不允许通过手动新闻接口删除。")
await db.execute(delete(EarthNewsItem).where(EarthNewsItem.id == item_id))
await db.flush()
return True
async def reprocess_manual_news_item(db: AsyncSession, item_id: str) -> bool:
record = await db.get(EarthNewsItem, item_id)
if record is None:
return False
if not _manual_editable(record):
raise PermissionError("RSS 新闻不允许通过手动新闻接口重新处理。")
item = record_to_parsed_news_item(record)
queued = await enqueue_target_location_job(build_target_location_job_payload(item), force=True)
if queued:
record.enrichment_status = NewsEnrichmentStatus.QUEUED.value
record.enrichment_error = None
await db.flush()
return queued
async def broadcast_manual_news_changed() -> None:
await _broadcast_news_reload()