refactor(news): remove retired research workflow
Development CI / check-and-build (push) Successful in 2m53s
GitHub Git mirror / mirror (push) Successful in 7s
Development CI / deploy-development (push) Successful in 2m31s

This commit is contained in:
eezd committed 2026-11-05 18:38:25 +08:00
1 parent f3894c3728
commit d79a10c2e6
69 files changed
+460 -6713

No files matched your search

@@ -0,0 +1,54 @@
"""Bounded public HTTP reads with DNS validation and redirect checks."""
import asyncio
import ipaddress
import socket
from datetime import UTC, datetime
from urllib.parse import urlsplit
import httpx
from parsel import Selector
async def public_address(url):
parsed = urlsplit(url)
if parsed.scheme not in ('http', 'https') or not parsed.hostname or parsed.username or parsed.password or parsed.port not in (None, 80, 443):
raise ValueError('只允许公开HTTP/HTTPS地址')
answers = await asyncio.get_running_loop().getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == 'https' else 80), type=socket.SOCK_STREAM)
addresses = [item[4][0] for item in answers]
if not addresses or any(not ipaddress.ip_address(ip).is_global for ip in addresses):
raise ValueError('不允许访问非公开地址')
return parsed, addresses[0]
async def fetch_public_response(url):
async with httpx.AsyncClient(timeout=20, trust_env=False) as client:
for _ in range(4):
parsed, address = await public_address(url)
# Pin the checked address while retaining TLS hostname verification.
target = httpx.URL(url).copy_with(host=address)
async with client.stream('GET', target, headers={'Host': parsed.hostname}, extensions={'sni_hostname': parsed.hostname}) as response:
if response.is_redirect:
location = response.headers.get('location')
if not location: raise ValueError('重定向缺少地址')
url = str(httpx.URL(url).join(location)); continue
chunks, size = [], 0
async for chunk in response.aiter_bytes():
size += len(chunk)
if size > 2*1024*1024: raise ValueError('响应超过2MiB限制')
chunks.append(chunk)
body = b''.join(chunks)
# Bytes are already decompressed; Scrapy must not decode them again.
headers = [(k, v) for k, v in response.headers.multi_items()
if k.lower() not in ('content-encoding', 'content-length', 'transfer-encoding')]
return httpx.Response(response.status_code, headers=headers, content=body, request=httpx.Request('GET', url))
raise ValueError('重定向超过限制')
async def fetch_public(url):
response = await fetch_public_response(url)
response.raise_for_status()
content_type = response.headers.get('content-type', '')
if 'html' in content_type:
selector = Selector(text=response.text)
content = '\n'.join(selector.xpath('//body//text()[not(ancestor::script or ancestor::style or ancestor::nav)]').getall())
elif any(t in content_type for t in ('text/', 'xml', 'json')):
content = response.text
else: raise ValueError('该来源不是可读取的文本正文')
return {'url': str(response.url), 'title': '', 'content': content, 'fetched_at': datetime.now(UTC).isoformat()}