、、input/textarea placeholder、
* img 的 alt/title、Microdata(itemprop 的 content 值与 url/sameAs 地址)、
* 可视文本节点,以及 JSON-LD 结构化数据里的少数文本字段与站内地址(见
* collect_jsonld());其余 与多出来的结束标签又被丢弃——输出的
* 页面标签就乱了。内容换成占位后 libxml 只看到一段没有标签的文本,不会误判。
*
* 这些内容本来就不翻译(提取时 script/style 一律跳过),藏起来没有副作用。
*
* @param string $html 源 HTML。
* @param array $store 出参:占位符 => 原内容。
* @param array $jsonld 出参:其中属于 JSON-LD(application/ld+json)的占位符。
* @return string
*/
private static function mask_raw_text( $html, &$store, &$jsonld = null ) {
$store = array();
$jsonld = array();
$bag = array();
$ld = array();
$masked = preg_replace_callback(
'#(<(script|style)\b[^>]*>)(.*?)(\2\s*>)#is',
function ( $matches ) use ( &$bag, &$ld ) {
// 空的、或只有 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];
if ( preg_match( '@\btype\s*=\s*["\']?\s*application/ld\+json@i', $matches[1] ) ) {
$ld[] = $token;
}
return $matches[1] . $token . $matches[4];
},
$html
);
// 回溯超限等情况下 preg 会返回 null,此时按没藏过处理,至少不丢内容。
if ( null === $masked ) {
return $html;
}
$store = $bag;
$jsonld = $ld;
return $masked;
}
/**
* JSON-LD 里要翻译的文本字段。
*
* 只取给人看的标题与描述,它们多半就是页面的
和 meta description,
* 通常直接命中已有译文。text(FAQ 答案等)里常夹着 HTML,整段送翻容易把
* 标签弄坏,不收。
*/
const JSONLD_TEXT_KEYS = array( 'name', 'headline', 'alternativeHeadline', 'description', 'caption' );
/**
* 这些类型的 name 是公司名、人名、站名,不翻译。
*/
const JSONLD_KEEP_NAME_TYPES = array(
'Organization', 'Corporation', 'LocalBusiness', 'NewsMediaOrganization',
'EducationalOrganization', 'NGO', 'Person', 'Brand', 'WebSite',
);
/**
* 递归遍历 JSON-LD,登记要翻译的文本与要换语言的站内地址。
*
* 地址只认 http(s) 开头的本站绝对地址:url、@id、item 都是这种写法。
* @id 与引用它的地方用的是同一个字符串,一起改写后图内引用照样对得上。
* 资源地址(/wp-content/ 下的图片等)和带 {search_term_string} 的搜索模板
* 由 split_site_url() 排除,与页面上的链接一个口径。
*
* @param mixed $data 当前节点。
* @param array $path 从根到当前节点的键路径。
* @param string $token 所属脚本的占位符。
* @param bool $include_urls 是否登记地址。
* @param array $nodes 出参:节点列表(与 parse() 共用)。
* @param bool $keep_name 当前对象的 name 是否不翻译。
* @return void
*/
private static function collect_jsonld( $data, array $path, $token, $include_urls, array &$nodes, $keep_name ) {
if ( is_object( $data ) ) {
$types = isset( $data->{'@type'} ) ? (array) $data->{'@type'} : array();
$keep_name = (bool) array_intersect( array_map( 'strval', array_filter( $types, 'is_scalar' ) ), self::JSONLD_KEEP_NAME_TYPES );
foreach ( get_object_vars( $data ) as $key => $value ) {
$here = array_merge( $path, array( $key ) );
if ( is_string( $value ) ) {
self::collect_jsonld_string( (string) $key, $value, $here, $token, $include_urls, $nodes, $keep_name );
} else {
self::collect_jsonld( $value, $here, $token, $include_urls, $nodes, false );
}
}
return;
}
if ( is_array( $data ) ) {
// 数组元素继承所在字段的名字:sameAs、url 可能是一串地址。
$key = $path ? (string) end( $path ) : '';
foreach ( $data as $i => $value ) {
$here = array_merge( $path, array( $i ) );
if ( is_string( $value ) ) {
self::collect_jsonld_string( $key, $value, $here, $token, $include_urls, $nodes, $keep_name );
} else {
self::collect_jsonld( $value, $here, $token, $include_urls, $nodes, false );
}
}
}
}
/**
* collect_jsonld() 的叶子:判断一个字符串值是文本、地址,还是不该碰。
*
* @param string $key 字段名。
* @param string $value 字符串值。
* @param array $path 键路径。
* @param string $token 所属脚本的占位符。
* @param bool $include_urls 是否登记地址。
* @param array $nodes 出参。
* @param bool $keep_name name 是否不翻译。
* @return void
*/
private static function collect_jsonld_string( $key, $value, array $path, $token, $include_urls, array &$nodes, $keep_name ) {
$text = trim( $value );
if ( '' === $text ) {
return;
}
if ( preg_match( '@^https?://@i', $text ) ) {
if ( ! $include_urls ) {
return;
}
$parsed = self::split_site_url( $text );
if ( null === $parsed ) {
return;
}
$nodes[] = array(
'type' => 'jsonld_uri',
'node' => null,
'text' => $parsed['path'],
'md5' => md5( $parsed['path'] ),
'prefix' => $parsed['prefix'],
'token' => $token,
'path' => $path,
);
return;
}
if ( ! in_array( $key, self::JSONLD_TEXT_KEYS, true ) ) {
return;
}
if ( 'name' === $key && $keep_name ) {
return;
}
$nodes[] = array(
'type' => 'jsonld',
'node' => null,
'text' => $text,
'md5' => md5( $text ),
'token' => $token,
'path' => $path,
);
}
/**
* 按键路径写回 JSON-LD 里的一个字符串值。
*
* @param mixed $data 解码后的数据(对象或数组),按引用修改。
* @param array $path 键路径。
* @param mixed $value 新值。
* @return void
*/
private static function jsonld_set( &$data, array $path, $value ) {
if ( ! $path ) {
$data = $value;
return;
}
$key = array_shift( $path );
if ( is_object( $data ) && property_exists( $data, (string) $key ) ) {
self::jsonld_set( $data->{$key}, $path, $value );
} elseif ( is_array( $data ) && array_key_exists( $key, $data ) ) {
self::jsonld_set( $data[ $key ], $path, $value );
}
}
/**
* 把 JSON-LD 里所有字符串形式的 inLanguage 换成目标语言。
*
* @param mixed $data 解码后的数据,按引用修改。
* @param string $lang 语言标签,如 es、en-US。
* @return void
*/
private static function jsonld_in_language( &$data, $lang ) {
if ( is_object( $data ) ) {
foreach ( get_object_vars( $data ) as $key => $value ) {
if ( 'inLanguage' === $key && is_string( $value ) ) {
$data->{$key} = $lang;
} else {
self::jsonld_in_language( $data->{$key}, $lang );
}
}
} elseif ( is_array( $data ) ) {
foreach ( array_keys( $data ) as $i ) {
self::jsonld_in_language( $data[ $i ], $lang );
}
}
}
/**
* 把 JSON-LD 编码回脚本内容。
*
* 斜杠保持转义、<> 编成 \u003C \u003E:内容里无论如何写不出 或