简介:面向WordPress用户的越南新闻自动抓取工具,用于定期从vnexpress.net获取最新文章并发布到自己的站点,解决博客主或新闻管理员手动复制粘贴的繁琐问题,让内容保持实时更新。资源包共15个文件,以PHP脚本和simple_html_dom解析库为主干,附带CSS样式、JavaScript交互、字体图标等前端资源,从数据抓取到页面呈现均有覆盖,整体仅253KB,轻量易部署。已有207人学习下载,适合具备一定PHP基础、希望实现内容自动化同步的WordPress站点运营者。通过这个资源可以梳理出网络爬虫、HTML解析、WordPress数据入库的完整实现路径,核心代码注释清晰,可直接修改参数后部署,快速搭建属于自己的新闻聚合功能。使用时建议先查阅vnexpress.net的robots.txt及版权声明,合理设置抓取频率,确保采集行为符合站点规则与当地法律。
1. 先定位这个 zip 的真实用途:VnExpress 新闻抓取脚本能解决什么
拿到get-news-vnexpress-net.zip这个命名的压缩包,多数人想要的是一段能直接抓取 VnExpress 新闻的脚本。VnExpress 是越南访问量最大的新闻门户,抓它的需求一般来自两类场景:一是做越南语语料积累,为后续的摘要生成、情感分析准备数据;二是做内容监控,把指定分类下的新文章定时拉进自己的系统。从 en.pudn.com 这类代码分享站能搜到不少同名项目,但老脚本的通病是依赖陈旧、选择器过时、编码处理粗糙,直接跑大多会翻车。这篇就顺着这个方向,把从页面分析、字段抽取到翻页增量、限速避坑的完整链路讲清楚,让新手能照着一小时搭起可用版本,老手也能在参数和边界上少踩几个坑。
2. 拆 VnExpress 页面结构与请求特征:先知道抓什么,再决定怎么写脚本
2.1 VnExpress 的分类 URL 规律与页面编码
VnExpress 的网址结构比想象中规整。新闻列表按分类挂在主域名下,路径就是分类名,例如https://vnexpress.net/the-thao是体育,https://vnexpress.net/kinh-doanh是经济,https://vnexpress.net/giao-duc是教育。文章详情页则是数字 ID 加短标题拼音,URL 尾部多带一组数字,例如/moto-gp-2024-...-12345.html这种形态。全站输出 UTF-8,<head>里的og:title、og:description、article:published_time这些 meta 字段齐全。这对抓取端是个好消息——意味着你不用去猜发布时间,直接从 meta 里读就行,省掉一整段时间解析的麻烦。
但 VnExpress 有一个让老脚本集体翻车的特点:列表页的 DOM 结构不定期改版,特别是首页和分类页的标题区块。今天的列表项可能是h3包一个<a>,三个月后就可能换成h2加># -*- coding: utf-8 -*- import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0 Safari/537.36", "Accept-Language": "vi-VN,vi;q=0.9,en;q=0.8", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", }) retry = Retry(total=3, connect=2, read=2, backoff_factor=0.8, status_forcelist=[500, 502, 503]) adapter = HTTPAdapter(max_retries=retry) session.mount("https://", adapter) session.mount("http://", adapter) def fetch_page(url, timeout=(8, 15)): resp = session.get(url, timeout=timeout) resp.raise_for_status() return resp.text
这段代码有四个容易被忽略的细节。第一,Accept-Language显式声明了越南语优先,避免站点按浏览器语言跳转到英文版;英文版域名是独立的e.vnexpress.net,脚本里如果没判断好域名,抓回来的标题会是英越混杂。第二,Retry里的status_forcelist只重试 5xx,不重试 4xx——403 再重试也是浪费请求,不如直接暴露错误去查原因。第三,backoff_factor=0.8表示第一次重试等 0.8 秒,第二次 1.6 秒,第三次 3.2 秒,这个递增节奏对新闻站点足够温和。第四,超时用二元组(连接超时, 读超时),连接 8 秒、读取 15 秒;只写一个数字的话,请求真正卡死时可能等上几十秒才发现,日志里全是假死。
2.3 lxml XPath 解析:正则只用来提取数字 ID
列表页拿回来之后,拆页面常见的错误是上来就写正则。正则适合提取 ID 和纯文本片段,不适合做结构定位,因为 VnExpress 的 HTML 里标签嵌套很深,一段正则很难覆盖所有边界。我一般直接用lxml.html的 XPath:
from lxml import html def parse_list(page_html): doc = html.fromstring(page_html) items = doc.xpath("//h3[contains(@class,'title-news')]/a") result = [] for a in items: href = a.get("href") title = a.text_content().strip() result.append({"title": title, "url": href}) return result这段代码的选择器是示例,核心思想是:用contains(@class, ...)而不是@class='title-news',因为真实页面里类名往往是"title-news other-class"这种组合写法,精确匹配会漏掉一半条目。拿到链接后,再补一步链接清洗,把末尾的 UTM 追踪参数去掉,避免同一篇文章因为参数不同入库两次。VnExpress 的列表页链接经常带?utm_source=...这类参数,不去参数的话,去重表里同一个 ID 会被识别成两篇,这点特别隐蔽。
from urllib.parse import urlparse, urlunparse def clean_url(raw): parts = urlparse(raw) return urlunparse((parts.scheme, parts.netloc, parts.path, "", "", ""))正则在这里的用途反而只剩一个——从 URL 里把文章数字 ID 抠出来。注意越南语新闻 URL 结尾并不总是.html,新站有时是纯路径格式,所以 ID 提取要写成容错返回None,而不是让整个函数崩溃。整体思路是:正则负责精确提取数字,XPath 负责容器定位,两者分工,互不越界。
import re def extract_article_id(url): m = re.search(r"/(\d+)\.html(?:$|[/?])", url) return int(m.group(1)) if m else None3. 把列表页变成结构化新闻数据:正文抽取与字段清洗
3.1 从列表页抽取标题、摘要与分类
列表页解出来的数据一般只有标题和链接,摘要要不要抓取决于用途。如果只是做标题级监控,抓列表页就够了;如果要拿去做语料训练,就必须进详情页。折中做法是列表页把标题、链接、分类、抓取时间抓下来,详情页只对新增的文章补抓正文。这样既能保证列表页的轻量,又能控制详情页的请求量。
分类信息在 VnExpress 列表页里有明显锚点:面包屑导航的最后一个文本就是分类名,比如Thể thao、Kinh doanh。用 XPath 拿面包屑里最后一个元素:
def parse_category(doc): crumbs = doc.xpath("//ul[contains(@class,'breadcrumb')]//li//text()") cats = [c.strip() for c in crumbs if c.strip()] return cats[-1] if cats else None这里有个细节:VnExpress 的标题区块会出现在首页轮播、分类页置顶、侧栏推荐多个位置,同一个分类页里有重复文章。解决办法是不要依赖区块类名去重,而是全部按链接去重——先clean_url再extract_article_id,以文章 ID 为字典键,后面的重复项自然覆盖。订阅源场景下还会混入来自其他站点的转载链接,ID 提取失败时统一丢弃并在日志里计数,不要静默放过。
3.2 详情页正文抽取的三个锚点
正文抽取是整条链路里最容易翻车的一步。VnExpress 详情页的结构通常有规律可循——有一个正文容器,里面是多个<p>段落。但容器类名会变,不变的是三个东西:<h1>标题、meta[property="article:published_time"]发布时间、以及正文容器里<p>标签数量最多的那个块。根据这三个锚点,一个稳妥的正文抽取策略是:先尝试匹配明确的正文容器类名,比如article-body、fck_detail这一类候选;匹配不到时,退回到"找出页面中<p>元素数量最多的父容器"这个启发式。
def find_body(doc): candidates = doc.xpath("//div[contains(@class,'fck_detail') or " "contains(@class,'article-body') or " "contains(@class,'content-detail')]") if candidates: return candidates[0] # 没命中已知类名时,按段落数最多的容器兜底 paras = doc.xpath("//div[.//p]") if not paras: return None best = max(paras, key=lambda d: len(d.xpath(".//p"))) return best if len(best.xpath(".//p")) >= 3 else None def extract_body(doc): node = find_body(doc) if node is None: return "" paragraphs = node.xpath(".//p[not(.//script)][not(.//style)]//text()") return "\n".join(p.strip() for p in paragraphs if p.strip())启发式回退有一个前提条件:至少三个<p>才认为候选容器有效,不然会把页脚的 "Xem thêm" 相关推荐块当成正文。这个阈值不是玄学,是多个新闻站抽取里比较稳定的经验值——正文少于三个段落时,这篇文章基本没有采集价值。抽取出的段落里偶尔会混入图片说明文字(figcaption内容),如果追求纯文本,可以在 XPath 里再加一个[not(ancestor::figure)]的过滤条件。
3.3 时间、作者字段:meta 优先,正则兜底
发布时间建议优先从<meta>里读,不要从页面可见文本里解析。VnExpress 的标准 meta 是:
<meta property="article:published_time" content="2024-05-18T09:30:00+07:00"/>解析时要注意时区后缀+07:00,这是越南标准时区,存库之前最好统一转成 UTC,或者统一转成自己的东八区,避免后面按时间做增量更新时算错窗口:
from datetime import datetime, timezone, timedelta VIE_TZ = timezone(timedelta(hours=7)) def parse_pubtime(meta_content): try: dt = datetime.fromisoformat(meta_content.replace("Z", "+00:00")) return dt.astimezone(timezone.utc).isoformat() except ValueError: return None这段代码把抓回来的+07:00时间统一转成 UTC。如果 meta 缺失,再退回去用正则匹配页面里的time标签或者 JSON-LD 结构里的datePublished。注意切忌把越南语里的月份缩写硬编码进正则,越南语月份不是英文 Jan/Feb 那一套,直接用数字正则(\d{4})-(\d{2})-(\d{2})最省事,坑最少。作者字段在 VnExpress 里经常出现在正文末尾的一个<p>标签,格式是Theo ...或直接是人名,这类字段对语料场景价值不大,我一般直接留空,不花太多精力解析。
4. 翻页策略、增量更新与限速:让抓取脚本稳定跑一个月不翻车
4.1 VnExpress 的翻页 URL 规律
列表页的翻页有两种常见形式。老式写法是查询参数?trang=2,新式写法是路径后缀/p-2。如果你手里的get-news-vnexpress-net脚本还在拼查询参数,先确认当前站点到底吃哪一种,拼错的情况下服务器返回的还是第一页内容,不会报错,但你会默默丢数据。判断方法很简单:手动打开分类页,翻到第二页,看浏览器地址栏的变化。
拿到真实翻页格式后,把页码参数化。注意第一页通常没有页码后缀,从第二页才出现,所以循环起点要写成range(1, max_page + 1),第一页单独拼 URL。如果多抓了一遍第一页,后面靠 ID 去重也能兜住,但白费一次请求,所以 URL 拼接逻辑越干净越好。另外一个容易忽略的点是页数与文章数量并不是线性关系:VnExpress 的分类列表页每页通常固定 20 条左右,但翻到 20 页以后经常返回空列表,不要用"空列表就停"作为唯一的退出条件,应该同时限制最大页数和已找到的最早发布时间。
def list_page_url(category, page): if page <= 1: return f"https://vnexpress.net/{category}" return f"https://vnexpress.net/{category}/p-{page}"4.2 增量更新:时间窗口与去重表双保险
刚开始跑脚本的人最容易犯的错是每次全量抓,抓完存起来就完事。全量抓的问题在于:VnExpress 首页和分类页只保留最近几十篇文章,深翻页超过一定页数后返回的都是空列表,你以为抓完了,实际只抓到表面几页。所以长效机制一定是增量更新——每次跑任务时记录上次抓取的时间窗口,只抓这个窗口内的新文章。
增量实现有两种层次。低配版是本地维护一个last_run时间戳,每次翻页时判断文章发布时间是否早于last_run,早于就停止翻页。高配版是把已抓文章 ID 存进 SQLite 或 Redis,翻页时用 ID 判断是否见过,见过就跳过。我一般两个都做,用 ID 去重做精确控制,用时间窗口做兜底——万一某篇文章时间解析失败,ID 去重仍能阻止重复入库。
def should_process(article_id, seen_ids, last_run_ts): if article_id in seen_ids: return False if article.pubtime < last_run_ts: return False return True注意增量更新里有个时间边界问题:分类页的置顶文章可能在last_run之前就发布,但它此刻仍然显示在列表第一屏。如果严格按时间过滤,这类文章会被漏掉。解决办法是保留一个小的时间缓冲,比如last_run - 6小时,在这个缓冲区间内的文章进详情页再做一次完整读取。缓冲设得太大浪费请求,设得太小漏置顶,6 小时是我自己的经验值,适用大多数 24 小时更新的新闻站。
4.3 限速、重试与礼貌抓取的参数设置
新闻站的反爬策略和电商站不是一个量级,但不代表可以乱来。VnExpress 对高频请求的容忍度并不高,连续几秒内几十个请求就可能触发风险控制,然后你的会话开始收到 403 或者重定向到验证页。关于限速参数,我一般这样设置:列表页每两次请求间隔 3 秒,详情页间隔 5 秒;如果某个分类的文章量特别大,把间隔提到 8 秒,宁可慢,不要断。下面这张参数表可以直接抄:
| 场景 | 间隔 | 随机抖动 | 连续请求上限 |
|---|---|---|---|
| 列表页翻页 | 3.0s | +0.5~1.5s | 每 10 页休息 10s |
| 详情页抓正文 | 5.0s | +1.0~2.0s | 每 50 篇休息 20s |
| 重试等待 | 0.8s 起步翻倍 | 不加抖动 | 最多 3 次 |
import time import random def polite_wait(interval=3.0): time.sleep(interval + random.uniform(0.5, 1.5))固定间隔sleep(3)的请求模式在服务器日志里呈现为非常规律的尖峰,很容易被限流规则识别。加一个 0.5 到 1.5 秒的随机抖动,把模式打散,看着更像人工阅读。还有一个隐藏参数:同一次任务里,不要对一个分类从第 1 页连续翻到第 30 页不停歇,每翻 5 页插入一次稍长的停顿(比如 10 秒),这个细节对稳定长期抓取很有帮助。跑一晚上再去看日志,如果发现连续 403 集中在某一个分类,多半不是频率问题,而是那个分类的 URL 拼错了,先检查list_page_url生成的地址。
5. 避坑:从 en.pudn.com 这类分享站搬代码,五个常见问题
5.1 ZIP 损坏或提示无效归档:先查文件完整性,再查后缀
现象:下载回来的get-news-vnexpress-net.zip解压报错,提示类似invalid zip archive: could not find EOCD,或者解压到一半CRC failed。
原因:分享站的下载链接经常经过多次转跳,下载中断后浏览器会留下一个不完整的 zip 文件,这是最常见的;其次是文件被二次打包,真实格式不是 zip,可能是 rar 或 7z,只是扩展名叫.zip;还有些包加了密码,解压工具不提示密码,直接报格式错误。如果你在 Linux 上重打包时用的命令不对,比如把外层文件夹一起zip -r进去,还会出现解压后多一层目录、脚本相对路径全乱的怪问题。
解决:先用file命令看真实格式:
file get-news-vnexpress-net.zip输出Zip archive data才是真的 zip,输出RAR archive data就改名用7z解。接着用 Python 里的zipfile做快速校验,找出具体是哪个文件损坏:
import zipfile zf = zipfile.ZipFile("get-news-vnexpress-net.zip") bad = zf.testzip() print("第一个损坏的文件:", bad)如果返回None,说明归档本身完整。剩下就是密码问题,在分享站下载区找说明,这类站点通常会把密码写在资源描述页里;描述页也找不到的话,不如放弃这个包,按前面几章的逻辑手写一个抓取器更省时间。分享站的老脚本就那么几十行,核心价值全在 URL 规律和字段命名,不值得为它花一小时解密码。
5.2 脚本是 Python 2 写的,率印和字符串处理全乱
现象:运行脚本直接报SyntaxError: invalid syntax,或者 print 后跟一堆括号报错,越南语字符串打印出来是UnicodeDecodeError。
原因:pudn 上很多get-news-vnexpress-net的老版本是 Python 2 时代写的,文件头可能都没有# -*- coding: utf-8 -*-,字符串默认按 ASCII 处理,遇到越南语字符必然炸。还有一类脚本是 PHP 写的,里面出现zip://或phar://这类流协议来读压缩资源,这种写法在 PHP 7.4 之后基本是历史遗留,环境里启不启用对应的 filter 都是个问题,兼容性很让人头疼。
解决:分情况处理。Python 2 脚本先确认解释器版本,改文件头的 shebang 为python3;然后把所有print语句改函数式调用,urllib.urlopen换成requests。如果脚本只有几十行,重写解析部分反而更快——老脚本的价值只在 URL 规律和字段命名,代码本体不值得抢救。PHP 脚本里遇到zip://或phar://的调用,直接替换成ZipArchive类读取,不要再依赖老的流协议,安全性和可维护性都更好。
5.3 解析选择器基于旧版 HTML,抓到的是空列表
现象:脚本运行不报错,日志显示请求成功,但解析完的文章列表是 0 条。
原因:VnExpress 改版后,老脚本的 XPath/CSS 选择器没有匹配到任何节点,parse_list返回空数组。很多老代码还不会打印警告,直接继续翻页,看起来一切正常,实际一条数据都没入库。这个问题在分类页和首页同时出现时排查很快,只出现在部分区块时最迷惑——比如首页轮播区结构改了,但分类列表区还是旧的。
解决:在解析函数里加断言,列表为空就抛异常退出,不要让任务静默成功。加一个最小日志:
if not result: raise RuntimeError(f"解析列表为空,请检查页面结构:{url}")然后把当前页面存成debug.html,用浏览器打开对比结构,重新定位选择器。这个习惯能省掉大量摸黑排查的时间。如果站点改版后新类名和旧类名共存,可以把新旧类名都写进 XPath 的候选列表里,兼容过渡期,等确认旧结构彻底下线再去掉。
5.4 越南语内容入库变问号或 utf-8 编码错误
现象:抓回来的标题在终端是乱码,存 MySQL 后变成一串???,或者写入时直接抛UnicodeDecodeError。
原因:多半是用了str()直接处理字节串,或者数据库连接字符集没设置成utf8mb4。越南语里有一堆带声调的字符,比如ế、ộ、ữ,这些在utf8mb4下没问题,在latin1或utf8下就会出问题。如果你是自己装 MySQL 的 zip 免安装版,字符集配置那块特别容易漏,默认utf8和真正需要的utf8mb4差很远。
解决:请求响应用resp.text而不是resp.content,让 requests 按页面声明的编码解码;数据库连接串显式加charset=utf8mb4;入库之前在代码里统一做一次 Unicode 规范化:
import unicodedata def normalize_vietnamese(text): if not text: return text return unicodedata.normalize("NFC", text)NFC规范化会把越南语的组合声调字符合并成单一码位,既省存储空间,又避免后续做关键词匹配时同一个词拆成两种写法。很多从分享站拿来的脚本在这一步都是裸奔的,库表里存进去的数据看似能看,一查重复率就是灾难。
5.5 高频请求触发风险控制,被 403 或重定向拦截
现象:跑前几十条正常,跑着跑着开始大量 403,或者返回一个内容完全不同的安全验证页。
原因:并发太高或频率固定,会话被标记为异常流量。多数老脚本没有限速机制,for循环里直接requests.get一个接一个地打,快的时候每秒能发几十个请求。还有一个常见诱因是 User-Agent 太明显,默认的python-requests/2.x一眼就能识别。
解决:不要用多线程,先单线程加间隔跑通再考虑加速;session.headers里补一个完整且真实的 User-Agent,不要用 Python 默认的;每次请求后检查resp.status_code和最终 URL,如果发现跳转到验证类页面,立即停止脚本并降低频率。
def safe_get(session, url, **kwargs): resp = session.get(url, **kwargs) if resp.status_code == 403: raise RuntimeError("收到 403,需要降速或检查 UA") if resp.url != url: raise RuntimeError(f"被重定向到非预期页面:{resp.url}") return resp重定向检查这招很快,如果 URL 后缀从/the-thao变成了/security-check之类的路径,说明流量已被标记,继续跑只会加重封禁。停半小时再降速重跑,是最直接的后悔药。
6. 把抓取器改造成自己的:SQLite 去重、配置化管理与结果验证
6.1 用 SQLite 做文章 ID 去重
全量抓改成增量抓,去重表是第一件事。SQLite 单文件部署、零运维,最适合这种单机采集任务。建表 SQL 很简单:
CREATE TABLE IF NOT EXISTS articles ( article_id INTEGER PRIMARY KEY, title TEXT NOT NULL, url TEXT NOT NULL, category TEXT, pubtime TEXT, body TEXT, fetched_at TEXT DEFAULT (datetime('now')) );插入时用INSERT OR IGNORE按主键去重,配合上一章的extract_article_id,同一篇文章无论被抓多少次,库里只有一条。这个小改动能让整个脚本从"每次全量重抓"变成"只补新的"。
6.2 配置化管理分类、间隔与时间窗口
脚本跑稳之后,把可调参数抽到一个配置里,不要散落在代码各处。我一般用一个config.py保存全站分类、请求间隔、时间缓冲:
config = { "categories": ["the-thao", "kinh-doanh", "giao-duc"], "list_interval": 3.0, "detail_interval": 5.0, "max_pages": 20, "time_buffer_hours": 6, }这样换一个分类、调一次频率,改配置就行,不用动解析逻辑。分享站的老脚本里,这些参数全是硬编码的,这也是为什么不建议直接跑老代码的原因之一——它的抓取节奏是为当年的站点负载设计的,现在用大概率被限流。
6.3 用 curl 验证解析结果:对比线上结构与本地渲染
每次改完选择器,先用一条命令验证页面还认不认识你的解析逻辑:
curl -s -H "User-Agent: Mozilla/5.0" \ -H "Accept-Language: vi-VN,vi;q=0.9" \ https://vnexpress.net/the-thao -o /tmp/debug.html python3 -c "from parse import parse_list; print(parse_list(open('/tmp/debug.html').read())[:3])"打印出前三条数据结构,肉眼扫一遍标题和链接,就能确认解析逻辑没跑偏。这一步特别适合在站点改版后快速自检,比直接跑完整任务看日志效率高得多。
我做采集项目到现在,最深的体会是:抓取脚本的价值一半在解析规则,另一半在出问题时能不能快速定位。每次改完代码,我都会先跑curl存一份 HTML 留档,再跑解析函数,最后才动完整任务。这个习惯帮我避开了很多次"看起来正常、实际上数据全空"的翻车事故,希望帮到你。
本文还有配套的精品资源,点击获取