diff --git a/laravel/app/ContentCenter/ArticleArtifact.php b/laravel/app/ContentCenter/ArticleArtifact.php
index 7a520a37..757e0e72 100644
--- a/laravel/app/ContentCenter/ArticleArtifact.php
+++ b/laravel/app/ContentCenter/ArticleArtifact.php
@@ -9,6 +9,7 @@ final class ArticleArtifact
public function build(array $data): array
{
validator($data, ['source_key' => 'required|string|max:160', 'revision' => 'required|string|size:64', 'generation' => 'required|integer|min:1', 'title' => 'required|string|max:200', 'text' => 'present|string|max:100000', 'sent_at' => 'required|date', 'images' => 'present|array|max:30', 'images.*.sha256' => 'required|regex:/^[a-f0-9]{64}$/D', 'images.*.mime' => 'required|in:image/png,image/jpeg,image/webp,image/gif', 'images.*.bytes' => 'required|integer|min:1|max:20971520'])->validate();
+ validator($data, ['links' => 'sometimes|array|max:100', 'links.*.offset' => 'required|integer|min:0', 'links.*.length' => 'required|integer|min:1', 'links.*.url' => 'required|string|max:4096'])->validate();
$code = 'telefox-'.substr(hash('sha256', $data['source_key']), 0, 24);
$body = "
\n".implode("\n", array_map(fn ($s) => '
'.htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'
', explode("\n", $data['text'])))."\n
\n\n";
$review = ReviewFormat::build($data);
@@ -24,7 +25,7 @@ final class ArticleArtifact
$document = \App\Services\Ranking\ArticleDocument::enrich($document);
}
- return ['source_key' => $data['source_key'], 'revision' => $data['revision'], 'generation' => $data['generation'], 'article_code' => $code, 'relative_path' => 'jccp/'.$code.'.md', 'permalink' => $meta['permalink'], 'title' => $document['meta']['title'], 'format_version' => 'ttc-native-v1', 'markdown' => "---\n".Yaml::dump($document['meta'], 6, 2)."---\n".$document['body'], 'images' => $data['images']];
+ return ['source_key' => $data['source_key'], 'revision' => $data['revision'], 'generation' => $data['generation'], 'article_code' => $code, 'relative_path' => 'jccp/'.$code.'.md', 'permalink' => $meta['permalink'], 'title' => $document['meta']['title'], 'format_version' => 'ttc-native-v2', 'markdown' => "---\n".Yaml::dump($document['meta'], 6, 2)."---\n".$document['body'], 'images' => $data['images']];
}
public static function extension(string $mime): string
diff --git a/laravel/app/ContentCenter/ContentController.php b/laravel/app/ContentCenter/ContentController.php
index 8402ee30..8b49c14f 100644
--- a/laravel/app/ContentCenter/ContentController.php
+++ b/laravel/app/ContentCenter/ContentController.php
@@ -6,6 +6,14 @@ use Illuminate\Http\Request;
final class ContentController
{
+ public function enrich(Request $request)
+ {
+ $data = $request->validate(['text' => 'present|string|max:100000']);
+ $url = PackageTable::source($data['text']);
+
+ return response()->json(['environment' => config('content_center.environment'), 'package_table' => $url ? app(PackageTable::class)->fetch($url) : null]);
+ }
+
public function preview(Request $request)
{
$artifact = app(ArticleArtifact::class)->build($request->all());
diff --git a/laravel/app/ContentCenter/LinkedText.php b/laravel/app/ContentCenter/LinkedText.php
new file mode 100644
index 00000000..f1ce7ed8
--- /dev/null
+++ b/laravel/app/ContentCenter/LinkedText.php
@@ -0,0 +1,56 @@
+'.self::escape($v).'';
+ }
+
+return self::escape($v);
+ }, preg_split('~(https?://[^\s<>\x22\x27]+)~u', $s, -1, PREG_SPLIT_DELIM_CAPTURE)));
+ }
+
+ public static function line(string $line, int $base, array $links): string
+ {
+ $u = mb_convert_encoding($line, 'UTF-16LE', 'UTF-8');
+ $n = intdiv(strlen($u), 2);
+ $cursor = 0;
+ $out = '';
+ usort($links, fn ($a, $b) => $a['offset'] <=> $b['offset']);
+ foreach ($links as $link) {
+ $start = $link['offset'] - $base;
+ $len = $link['length'];
+ if ($start < 0 || $start < $cursor || $start + $len > $n || ! self::safeUrl($link['url'])) {
+ continue;
+ }
+ $out .= self::plain(mb_convert_encoding(substr($u, $cursor * 2, ($start - $cursor) * 2), 'UTF-8', 'UTF-16LE'));
+ $label = mb_convert_encoding(substr($u, $start * 2, $len * 2), 'UTF-8', 'UTF-16LE');
+ $out .= ''.self::escape($label).'';
+ $cursor = $start + $len;
+ }
+
+ return $out.self::plain(mb_convert_encoding(substr($u, $cursor * 2), 'UTF-8', 'UTF-16LE'));
+ }
+}
diff --git a/laravel/app/ContentCenter/PackageTable.php b/laravel/app/ContentCenter/PackageTable.php
new file mode 100644
index 00000000..38f3c94c
--- /dev/null
+++ b/laravel/app/ContentCenter/PackageTable.php
@@ -0,0 +1,124 @@
+connectTimeout(8)->withoutRedirecting()->withUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/115.0 Safari/537.36')->withOptions(['progress' => static function ($t, $n) {
+ if ($t > 2097152 || $n > 2097152) {
+ throw new RuntimeException('套餐响应过大。');
+ }
+ }])->get($url);
+ if ($r->redirect()) {
+ $next = $r->header('Location');
+ if (str_starts_with($next, '/')) {
+ $next = 'https://'.parse_url($url, PHP_URL_HOST).$next;
+ }$url = $next;
+
+ continue;
+ }
+ if (! $r->successful() || strlen($r->body()) > 2097152) {
+ throw new RuntimeException('套餐表暂不可用,请重新准备稿件。');
+ }
+
+ return self::parse($r->body());
+ }
+ throw new RuntimeException('套餐来源跳转过多。');
+ });
+ }
+
+ public static function parse(string $html): array
+ {
+ $previous = libxml_use_internal_errors(true);
+ $dom = new \DOMDocument;
+ $dom->loadHTML(''.$html, LIBXML_NONET);
+ libxml_clear_errors();
+ libxml_use_internal_errors($previous);
+ $xpath = new \DOMXPath($dom);
+ $table = $xpath->query('//div[contains(concat(" ",normalize-space(@class)," ")," markdown-body ")]//table')->item(0);
+ if (! $table) {
+ throw new RuntimeException('来源没有套餐表,不能生成价格。');
+ }
+ foreach (iterator_to_array($xpath->query('.//script|.//style|.//iframe', $table)) as $node) {
+ $node->parentNode->removeChild($node);
+ }
+ $headers = [];
+ $rows = [];
+ foreach ($xpath->query('./thead/tr|./tbody/tr|./tr', $table) as $tr) {
+ $cells = [];
+ $isHeader = false;
+ foreach ($tr->childNodes as $td) {
+ if (! in_array($td->nodeName, ['th', 'td'], true)) {
+ continue;
+ }$isHeader = $isHeader || $td->nodeName === 'th';
+ $cells[] = trim(preg_replace('/\s+/u', ' ', $td->textContent));
+ }
+ if (! $cells) {
+ continue;
+ }if ($isHeader && ! $headers) {
+ $headers = $cells;
+ } else {
+ $rows[] = $cells;
+ }
+ }
+ $result = ['headers' => $headers, 'rows' => $rows];
+ self::validate($result);
+
+ return $result;
+ }
+
+ public static function validate(array $data): void
+ {
+ validator($data, ['headers' => 'required|array|size:7', 'headers.*' => 'required|string|max:100', 'rows' => 'required|array|min:1|max:100', 'rows.*' => 'array|size:7', 'rows.*.*' => 'present|string|max:1000'])->validate();
+ if (! in_array('流量', $data['headers'], true) || ! in_array('月付', $data['headers'], true)) {
+ throw new RuntimeException('套餐表头不匹配。');
+ }
+ }
+
+ public static function render(array $data): string
+ {
+ self::validate($data);
+ $out = "\n\n\n";
+ foreach ($data['headers'] as $i => $s) {
+ $out .= '| '.htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')." | \n";
+ }
+ $out .= "
\n\n\n";
+ foreach ($data['rows'] as $row) {
+ $out .= "\n";
+ foreach ($row as $i => $s) {
+ $out .= '| '.htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')." | \n";
+ }$out .= "
\n";
+ }
+
+ return $out."\n
\n";
+ }
+}
diff --git a/laravel/app/ContentCenter/ReviewFormat.php b/laravel/app/ContentCenter/ReviewFormat.php
index ecd63a4d..9ad9ea36 100644
--- a/laravel/app/ContentCenter/ReviewFormat.php
+++ b/laravel/app/ContentCenter/ReviewFormat.php
@@ -18,18 +18,25 @@ final class ReviewFormat
}
$scores[] = $name.':'.$m[1].' ';
}
- $lines = explode("\n", trim($text));
+ $lines = explode("\n", $text);
+ $skipTitle = false;
$title = $data['title'];
if (preg_match('/^#([^\s#]+)/u', trim($lines[0]), $m)) {
if (str_starts_with(trim($title), '#')) {
$name = preg_replace('/机场$/u', '', $m[1]);
$title = $name.'机场测评 & 测速';
}
- array_shift($lines);
+ $skipTitle = true;
}
$sections = ['intro' => [], 'navigation' => [], 'review' => [], 'extra' => []];
$state = 'extra';
- foreach ($lines as $line) {
+ $cursor = 0;
+ foreach ($lines as $index => $line) {
+ $offset = $cursor;
+ $cursor += LinkedText::length($line) + 1;
+ if ($index === 0 && $skipTitle) {
+ continue;
+ }
$label = trim(preg_replace('/^[^\p{L}\p{N}]+/u', '', trim($line)));
if (preg_match('/^主观评分/u', $label)) {
$state = 'rating';
@@ -50,7 +57,8 @@ final class ReviewFormat
$state = $key;
$rest = trim(mb_substr($label, mb_strlen($heading)), " ::\t");
if ($rest !== '') {
- $sections[$key][] = $rest;
+ $prefix = mb_strpos($line, $rest);
+ $sections[$key][] = ['text' => $rest, 'offset' => $offset + LinkedText::length(mb_substr($line, 0, $prefix))];
}
$matched = true;
break;
@@ -59,29 +67,38 @@ final class ReviewFormat
if ($matched || in_array($state, ['rating', 'reminder'], true)) {
continue;
}
- $sections[$state][] = $line;
+ if ($state === 'navigation' && preg_match('/^(?:论坛|套餐)[::]/u', $label)) {
+ continue;
+ }
+ $sections[$state][] = ['text' => $line, 'offset' => $offset];
}
$body = "::: tip 天天测提醒您,任何机场都有跑路可能,建议仅月付,以降低风险。(不针对任何机场) \n::: \n\n";
$body .= "::: important 天天测打分 \n".implode("\n", $scores)."\n::: \n\n";
- if (trim(implode('', $sections['review'])) !== '') {
- $body .= "::: important 天天测点评 \n".self::paragraphs($sections['review'])."\n::: \n\n";
+ if (trim(implode('', array_column($sections['review'], 'text'))) !== '') {
+ $body .= "::: important 天天测点评 \n".self::paragraphs($sections['review'], $data['links'] ?? [])."\n::: \n\n";
}
foreach (['intro' => '机场自介', 'navigation' => '链接导航'] as $key => $heading) {
- if (trim(implode('', $sections[$key])) !== '') {
- $body .= '## '.$heading." \n\n".self::paragraphs($sections[$key])."\n\n";
+ if (trim(implode('', array_column($sections[$key], 'text'))) !== '') {
+ $body .= '## '.$heading." \n\n".self::paragraphs($sections[$key], $data['links'] ?? [])."\n\n";
}
}
- if (trim(implode('', $sections['extra'])) !== '') {
- $body .= self::paragraphs($sections['extra'])."\n\n";
+ if (trim(implode('', array_column($sections['extra'], 'text'))) !== '') {
+ $body .= self::paragraphs($sections['extra'], $data['links'] ?? [])."\n\n";
+ }
+ if (PackageTable::source($data['text']) && empty($data['package_table'])) {
+ throw new \RuntimeException('套餐尚未提取,请重新准备稿件。');
+ }
+ if (! empty($data['package_table'])) {
+ $body .= "## 套餐价格 \n\n".PackageTable::render($data['package_table'])."\n";
}
$body .= "## 节点测试 \n\n";
return ['title' => $title, 'tags' => ['机场测评', '每天测速'], 'body' => $body];
}
- private static function paragraphs(array $lines): string
+ private static function paragraphs(array $lines, array $links): string
{
// Keep channel text inert while using exactly the site's native containers/headings.
- return ''."\n".implode("\n", array_map(fn ($line) => trim($line) === '' ? '' : '
'.htmlspecialchars($line, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8').'
', $lines))."\n
";
+ return ''."\n".implode("\n", array_map(fn ($line) => trim($line['text']) === '' ? '' : '
'.LinkedText::line($line['text'], $line['offset'], $links).'
', $lines))."\n
";
}
}
diff --git a/laravel/content-center/routes.php b/laravel/content-center/routes.php
index b145f390..287f7a31 100644
--- a/laravel/content-center/routes.php
+++ b/laravel/content-center/routes.php
@@ -5,6 +5,7 @@ use App\ContentCenter\ContentController;
use Illuminate\Support\Facades\Route;
Route::prefix('api/content/v1')->middleware([ContentAuthenticate::class.':atom', 'throttle:30,1'])->group(function () {
+ Route::post('/enrich', [ContentController::class, 'enrich']);
Route::post('/delete', [ContentController::class, 'delete']);
Route::post('/preview', [ContentController::class, 'preview']);
Route::post('/publish', [ContentController::class, 'publish']);
diff --git a/laravel/tests/Feature/ContentCenterTest.php b/laravel/tests/Feature/ContentCenterTest.php
index 5442baac..283ac220 100644
--- a/laravel/tests/Feature/ContentCenterTest.php
+++ b/laravel/tests/Feature/ContentCenterTest.php
@@ -216,4 +216,63 @@ final class ContentCenterTest extends TestCase
$this->signed('/api/content/v1/delete', $data)->assertOk();
$this->assertFileDoesNotExist($this->root.'/vuepress/docs/'.$artifact['relative_path']);
}
+
+ private function packageFixture(): array
+ {
+ return ['headers' => ['套餐', '流量', '月付', '季付', '半年', '年付', '一次性'], 'rows' => [['基础', '100GB', '10.00', '×', '×', '×', '×']]];
+ }
+
+ public function test_hidden_official_link_and_separate_frozen_package_table_follow_legacy_layout(): void
+ {
+ $input = $this->reviewInput();
+ $input['text'] = str_replace('官网:点击进入', "官网:点击进入\n套餐:https://example.com/package\n论坛:https://www.ffqla.net/thread-932-1-1.html", $input['text']);
+ $offset = \App\ContentCenter\LinkedText::length(substr($input['text'], 0, strpos($input['text'], '点击进入')));
+ $input['links'] = [['offset' => $offset, 'length' => 4, 'url' => 'https://example.com/shop?a=1&b=2']];
+ $input['package_table'] = $this->packageFixture();
+ $a = app(ArticleArtifact::class)->build($input);
+ $md = $a['markdown'];
+ $this->assertStringContainsString('href="https://example.com/shop?a=1&b=2"', $md);
+ $this->assertStringContainsString('>点击进入', $md);
+ $this->assertStringContainsString('## 套餐价格', $md);
+ $this->assertStringContainsString('100GB', $md);
+ $nav = explode('## 套餐价格', explode('## 链接导航', $md)[1])[0];
+ $this->assertStringNotContainsString('论坛', $nav);
+ $this->assertStringNotContainsString('套餐', $nav);
+ Http::assertNothingSent();
+ $input['package_table']['rows'][0][2] = '12.00';
+ $this->assertNotSame(ArticleArtifact::hash($a), ArticleArtifact::hash(app(ArticleArtifact::class)->build($input)));
+ }
+
+ public function test_package_parser_uses_first_markdown_table_and_discards_active_markup(): void
+ {
+ $html = '';
+ foreach ($this->packageFixture()['headers'] as $v) {
+ $html .= '| '.$v.' | ';
+ }
+ $html .= '
';
+ foreach ($this->packageFixture()['rows'][0] as $v) {
+ $html .= '| '.$v.' | ';
+ }
+ $html .= '
';
+ $t = \App\ContentCenter\PackageTable::parse($html);
+ $this->assertSame($this->packageFixture(), $t);
+ $rendered = \App\ContentCenter\PackageTable::render($t);
+ $this->assertStringNotContainsString('script', $rendered);
+ $this->assertStringNotContainsString('onclick', $rendered);
+ }
+
+ public function test_missing_package_data_cannot_silently_publish_or_fetch_during_build(): void
+ {
+ $input = $this->reviewInput();
+ $input['text'] .= "\n论坛:https://www.ffqla.net/thread-932-1-1.html";
+ $this->expectException(\RuntimeException::class);
+ app(ArticleArtifact::class)->build($input);
+ }
+
+ public function test_package_fetch_rejects_redirect_to_unregistered_host(): void
+ {
+ Http::fake(['https://www.ffqla.net/*' => Http::response('', 302, ['Location' => 'http://127.0.0.1/private'])]);
+ $this->expectException(\RuntimeException::class);
+ app(\App\ContentCenter\PackageTable::class)->fetch('https://www.ffqla.net/thread-932-1-1.html');
+ }
}