、、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[^>]*>)(.*?)()#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; } }