、、input/textarea placeholder、
* img 的 alt/title、Microdata(itemprop 的 content 值与 url/sameAs 地址)、
* 可视文本节点;跳过 与多出来的结束标签又被丢弃——输出的
* 页面标签就乱了。内容换成占位后 libxml 只看到一段没有标签的文本,不会误判。
*
* 这些内容本来就不翻译(提取时 script/style 一律跳过),藏起来没有副作用。
*
* @param string $html 源 HTML。
* @param array $store 出参:占位符 => 原内容。
* @return string
*/
private static function mask_raw_text( $html, &$store ) {
$store = array();
$bag = array();
$masked = preg_replace_callback(
'#(<(script|style)\b[^>]*>)(.*?)(\2\s*>)#is',
function ( $matches ) use ( &$bag ) {
// 空的、或只有 src 的 script 没什么好藏。
if ( '' === trim( $matches[3] ) ) {
return $matches[0];
}
// 占位符只用 ASCII 字母数字:数字实体转换不会碰它,
// saveHTML 也不会转义 script 内容,出来时原样还在。
$token = 'apgtRaw' . count( $bag ) . substr( md5( $matches[3] ), 0, 12 ) . 'wARtcia';
$bag[ $token ] = $matches[3];
return $matches[1] . $token . $matches[4];
},
$html
);
// 回溯超限等情况下 preg 会返回 null,此时按没藏过处理,至少不丢内容。
if ( null === $masked ) {
return $html;
}
$store = $bag;
return $masked;
}
/**
* 收集页面里指向本站的 URL,交给 $add 登记成 uri 节点。
*
* 只收导航类地址(链接、canonical、表单 action、og:url、Microdata 的 url),
* 不碰 img/script/css 这些资源路径——资源没有翻译的意义,改了反而 404。
*
* @param DOMXPath $xpath XPath 实例。
* @param callable $add 登记回调,签名同 parse() 里的 $add。
* @return void
*/
private static function collect_urls( DOMXPath $xpath, $add ) {
// 元素 => 存放 URL 的属性名。link 只取 canonical/shortlink,避免误伤样式表。
// Microdata 的地址同样要跟着换语言,但只认 url/sameAs:image、logo、
// contentUrl 那些指向的是资源文件,动了就 404。
$targets = array(
'//a[@href]' => 'href',
'//link[@href][translate(@rel, "CANOSHRTLI", "canoshrtli") = "canonical" or translate(@rel, "CANOSHRTLI", "canoshrtli") = "shortlink"]' => 'href',
'//form[@action]' => 'action',
'//meta[@property="og:url"][@content]' => 'content',
'//*[@href][translate(@itemprop, "URLSAME", "urlsame") = "url" or translate(@itemprop, "URLSAME", "urlsame") = "sameas"]' => 'href',
'//*[@content][translate(@itemprop, "URLSAME", "urlsame") = "url" or translate(@itemprop, "URLSAME", "urlsame") = "sameas"]' => 'content',
);
foreach ( $targets as $query => $attr ) {
$found = $xpath->query( $query );
if ( ! $found ) {
continue;
}
foreach ( $found as $node ) {
if ( ! $node instanceof DOMElement ) {
continue;
}
$parsed = self::split_site_url( $node->getAttribute( $attr ) );
if ( null === $parsed ) {
continue;
}
$add(
'uri',
$node,
$parsed['path'],
array(
'attr' => $attr,
'prefix' => $parsed['prefix'],
)
);
}
}
}
/**
* Microdata 的某个 itemprop 值是不是「机器值」——不该送去翻译。
*
* 两道判断:
* 1. 属性名黑名单:价格、日期、编号、经纬度、资源地址这些译了就是破坏结构化
* 数据。url/sameAs 也在其中,它们由 collect_urls() 按地址逻辑单独处理。
* 2. 值的形态:即便属性名不认识,长得像网址、邮箱、纯数字、ISO 日期或时长的
* 也一律放过,免得黑名单漏了某个词就把数据翻坏。
*
* @param string $prop itemprop 属性值。
* @param string $value content 属性值。
* @return bool true 表示跳过。
*/
private static function is_machine_itemprop( $prop, $value ) {
static $skip = array(
// 地址与资源
'url', 'sameas', 'image', 'logo', 'photo', 'thumbnail', 'thumbnailurl',
'contenturl', 'embedurl', 'downloadurl', 'additionaltype', 'availability',
'itemcondition', 'encodingformat', 'inlanguage', 'target', 'urltemplate',
// 编号与标识
'identifier', 'sku', 'mpn', 'productid', 'isbn', 'issn', 'serialnumber',
'gtin', 'gtin8', 'gtin12', 'gtin13', 'gtin14', 'ordernumber', 'vatid',
// 数值
'price', 'pricecurrency', 'lowprice', 'highprice', 'ratingvalue',
'bestrating', 'worstrating', 'reviewcount', 'ratingcount', 'position',
'height', 'width', 'weight', 'contentsize', 'filesize', 'bitrate',
'latitude', 'longitude', 'postalcode', 'interactioncount', 'version',
// 日期与联系方式
'datepublished', 'datemodified', 'datecreated', 'uploaddate', 'startdate',
'enddate', 'validfrom', 'validthrough', 'pricevaliduntil', 'birthdate',
'duration', 'telephone', 'faxnumber', 'email',
);
$prop = strtolower( trim( (string) $prop ) );
// itemprop 允许空格分隔多个名字,任一命中即跳过。
foreach ( preg_split( '/\s+/', $prop, -1, PREG_SPLIT_NO_EMPTY ) as $one ) {
if ( in_array( $one, $skip, true ) ) {
return true;
}
}
$value = trim( (string) $value );
if ( '' === $value ) {
return true;
}
return (bool) preg_match(
'~^(
(https?:)?// # 绝对网址
| [^\s@]+@[^\s@]+\.[a-z]{2,} # 邮箱
| [++][\d\s()-/-]{5,}$ # 国际电话
| [\d.,]+$ # 纯数字
| \d{4}-\d{2}-\d{2} # ISO 日期
| PT?\d # ISO 时长 P1D / PT30M
| [A-Z]{3}$ # 货币代码 USD
)~xu',
$value
);
}
/**
* 节点自身或祖先上是否有「不要翻译」的标记。
*
* 认三种写法,class 一律按整词匹配(大小写不敏感),`xx-not-translate`、
* `not-translate-foo` 这类不会被误命中:
*
* - class `not-translate`:本插件的标记
* - class `notranslate`:Google 翻译的通行惯例
* - 属性 `translate="no"`:HTML 标准写法;内层 `translate="yes"` 可以反向开启
*
* 另有 class `not-translate-url`,是 website-dev 沿用下来的标记,只挡 URL
* 改写(链接照常翻译文字,但路径不动、不加语言前缀),故仅 $for_url 时生效。
*
* @param DOMNode $node 节点,文本节点会从其父元素开始向上找。
* @param bool $for_url 是否是 URL 节点(决定要不要认 not-translate-url)。
* @return bool
*/
private static function has_skip_marker( DOMNode $node, $for_url = false ) {
$markers = array( ' not-translate ', ' notranslate ' );
if ( $for_url ) {
$markers[] = ' not-translate-url ';
}
for ( $cur = $node; $cur instanceof DOMNode; $cur = $cur->parentNode ) {
if ( ! $cur instanceof DOMElement ) {
continue;
}
$class = (string) $cur->getAttribute( 'class' );
if ( '' !== $class ) {
$class = ' ' . strtolower( preg_replace( '/\s+/', ' ', trim( $class ) ) ) . ' ';
foreach ( $markers as $marker ) {
if ( false !== strpos( $class, $marker ) ) {
return true;
}
}
}
// translate 属性就近生效:先撞上 no 就跳过,先撞上 yes 就明确不跳过。
if ( $cur->hasAttribute( 'translate' ) ) {
$flag = strtolower( trim( $cur->getAttribute( 'translate' ) ) );
if ( 'no' === $flag || '0' === $flag ) {
return true;
}
if ( 'yes' === $flag || '1' === $flag || '' === $flag ) {
return false;
}
}
}
return false;
}
/**
* 判断一个 URL 是否指向本站,并拆成「协议主机前缀 + 站内路径」。
*
* 入库的一律是以 / 开头的路径,换域名或 http 转 https 都不影响译文命中;
* 回填时再把原来的前缀拼回去,绝对地址仍然是绝对地址。
*
* @param string $raw 属性里的原始值。
* @return array|null [ 'prefix'=>string, 'path'=>string ];不该翻译时返回 null。
*/
private static function split_site_url( $raw ) {
$url = trim( (string) $raw );
if ( '' === $url ) {
return null;
}
// HTML 属性里的 & 要先还原,否则 md5 和实际地址对不上。
$url = html_entity_decode( $url, ENT_QUOTES, 'UTF-8' );
// 含 {search_term_string} 这类模板变量的地址交给运行时去填,不翻译。
if ( preg_match( '@\{[a-z_]+\}@i', $url ) ) {
return null;
}
// 纯锚点、mailto:/tel:/javascript: 等非页面地址。
if ( '#' === $url[0] ) {
return null;
}
$scheme = wp_parse_url( $url, PHP_URL_SCHEME );
if ( $scheme && ! in_array( strtolower( $scheme ), array( 'http', 'https' ), true ) ) {
return null;
}
$host = wp_parse_url( $url, PHP_URL_HOST );
if ( $host ) {
$site_host = wp_parse_url( home_url(), PHP_URL_HOST );
if ( ! $site_host || strtolower( $host ) !== strtolower( $site_host ) ) {
return null; // 外站链接。
}
}
// 剥掉 scheme://host[:port],剩下 path + query + fragment。
$prefix = '';
if ( preg_match( '@^([a-z][a-z0-9+.\-]*:)?//[^/]*@i', $url, $m ) ) {
$prefix = $m[0];
$path = substr( $url, strlen( $prefix ) );
} else {
$path = $url;
}
if ( '' === $path ) {
$path = '/';
}
// 不以 / 开头的相对地址(about.html)依赖当前目录,改写会变语义,跳过。
if ( '/' !== $path[0] ) {
return null;
}
// apgt_src 是排查时手工加的标记(早先抓页面也会自动带上),真实页面上并不存在,
// 留在原文里既污染数据又永远匹配不上。
$path = self::strip_query_arg( $path, 'apgt_src' );
// DOM 写回属性时会把非 ASCII 百分号编码,这里先统一成同一种形态,
// 否则同一条 URL 第二次提取时 md5 就和库里对不上了。
$path = preg_replace_callback(
'/[^\x20-\x7E]/',
function ( $m ) {
return rawurlencode( $m[0] );
},
$path
);
if ( ! self::is_translatable_path( $path ) ) {
return null;
}
return array(
'prefix' => $prefix,
'path' => $path,
);
}
/**
* 从「path?query#fragment」里去掉某个查询参数。
*
* @param string $path 路径(含 query/fragment)。
* @param string $key 要去掉的参数名。
* @return string
*/
private static function strip_query_arg( $path, $key ) {
if ( false === strpos( $path, $key . '=' ) ) {
return $path;
}
$fragment = '';
$hash = strpos( $path, '#' );
if ( false !== $hash ) {
$fragment = substr( $path, $hash );
$path = substr( $path, 0, $hash );
}
$mark = strpos( $path, '?' );
if ( false === $mark ) {
return $path . $fragment;
}
$base = substr( $path, 0, $mark );
$query = substr( $path, $mark + 1 );
$keep = array();
foreach ( explode( '&', $query ) as $pair ) {
if ( '' === $pair ) {
continue;
}
$name = explode( '=', $pair, 2 )[0];
if ( $name !== $key ) {
$keep[] = $pair;
}
}
return $base . ( $keep ? '?' . implode( '&', $keep ) : '' ) . $fragment;
}
/**
* 这个站内路径值不值得翻译 / 加语言前缀。
*
* WordPress 的核心端点(评论提交、登录、REST、后台、静态资源)由代码按
* 固定地址请求,翻译过去只会 404,比如把 /wp-comments-post.php 换掉,
* 整个评论表单就废了。sitemap 与 robots 里的地址也照这套规则筛。
*
* @param string $path 站内路径。
* @return bool
*/
public static function is_public_path( $path ) {
return self::is_translatable_path( $path );
}
/**
* 见 is_public_path()。
*
* @param string $path 站内路径。
* @return bool
*/
private static function is_translatable_path( $path ) {
$lower = strtolower( $path );
$prefixes = array(
'/wp-admin/',
'/wp-content/',
'/wp-includes/',
'/wp-json/',
'/feed/',
);
foreach ( $prefixes as $prefix ) {
if ( 0 === strpos( $lower, $prefix ) ) {
return false;
}
}
// /wp-login.php、/wp-comments-post.php、/xmlrpc.php 这类入口文件。
if ( preg_match( '@^/(wp-[a-z0-9-]+|xmlrpc)\.php\b@i', $lower ) ) {
return false;
}
return true;
}
/**
* 输出回填后的 HTML。
*
* @param array $ctx parse() 的返回结构。
* @return string
*/
private static function render( array $ctx ) {
$ctx['dom']->formatOutput = false;
$html = $ctx['dom']->saveHTML();
// 把 saveHTML 写出的 NNNN; 解回真正的字符——页面本来就是 UTF-8,
// 不这么做整页中文都会变成一串数字实体。
$html = mb_decode_numericentity( $html, self::ENTITY_RANGE, 'UTF-8' );
// 还原 script / style 的内容。放在解实体之后:脚本里若写着 ' 这类
// 字面量,是它自己的内容,不该被当成我们编码出来的实体解掉。
if ( ! empty( $ctx['raw'] ) ) {
$html = strtr( $html, $ctx['raw'] );
}
if ( is_callable( $ctx['output_cb'] ) ) {
$html = call_user_func( $ctx['output_cb'], $html );
}
return $html;
}
}