fix: 修复WordPress主题源站抓取

This commit is contained in:
sunlei 2026-06-05 15:22:43 +08:00
parent 1a008ea860
commit 490632c7a1
3 changed files with 219 additions and 1 deletions

View File

@ -12,6 +12,7 @@ MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=kt-template-online MINIO_BUCKET=kt-template-online
WORDPRESS_BASE_URL=http://localhost WORDPRESS_BASE_URL=http://localhost
WORDPRESS_HOST_HEADER=
WORDPRESS_ADMIN_USERNAME=admin WORDPRESS_ADMIN_USERNAME=admin
WORDPRESS_ADMIN_PASSWORD= WORDPRESS_ADMIN_PASSWORD=
WORDPRESS_TIMEOUT_MS=15000 WORDPRESS_TIMEOUT_MS=15000

View File

@ -1,3 +1,6 @@
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import type { IncomingHttpHeaders } from 'node:http';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import type { Request, Response as ExpressResponse } from 'express'; import type { Request, Response as ExpressResponse } from 'express';
@ -628,6 +631,13 @@ export class WordpressService {
init: RequestInit = {}, init: RequestInit = {},
timeoutMs?: number, timeoutMs?: number,
) { ) {
const url = this.getUrl(path);
const hostHeader = this.getWordpressHostHeader();
if (hostHeader) {
return this.rawNodeRequest(url, init, timeoutMs, hostHeader);
}
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout( const timer = setTimeout(
() => controller.abort(), () => controller.abort(),
@ -635,7 +645,7 @@ export class WordpressService {
); );
try { try {
return await fetch(this.getUrl(path), { return await fetch(url, {
...init, ...init,
signal: controller.signal, signal: controller.signal,
}); });
@ -654,6 +664,140 @@ export class WordpressService {
} }
} }
private async rawNodeRequest(
url: string,
init: RequestInit,
timeoutMs: number | undefined,
hostHeader: string,
redirectCount = 0,
): Promise<globalThis.Response> {
const response = await this.executeRawNodeRequest(
url,
init,
timeoutMs,
hostHeader,
);
const shouldFollowRedirect =
(init.redirect || 'follow') !== 'manual' &&
response.status >= 300 &&
response.status < 400 &&
!!response.headers.get('location');
if (!shouldFollowRedirect) {
return response;
}
if (redirectCount >= 5) {
return response;
}
const nextUrl = new URL(response.headers.get('location') || '', url);
return this.rawNodeRequest(
nextUrl.toString(),
init,
timeoutMs,
hostHeader,
redirectCount + 1,
);
}
private async executeRawNodeRequest(
url: string,
init: RequestInit,
timeoutMs: number | undefined,
hostHeader: string,
): Promise<globalThis.Response> {
const target = new URL(url);
const request = target.protocol === 'https:' ? httpsRequest : httpRequest;
const headers = this.getRawNodeRequestHeaders(init.headers, hostHeader);
const body = this.getRawNodeRequestBody(init.body);
if (body && !headers['content-length']) {
headers['content-length'] = `${Buffer.byteLength(body)}`;
}
return new Promise((resolve, reject) => {
const req = request(
target,
{
headers,
method: init.method || 'GET',
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
res.on('error', reject);
res.on('end', () => {
resolve(
new Response(Buffer.concat(chunks), {
headers: this.toResponseHeaders(res.headers),
status: res.statusCode || HttpStatus.BAD_GATEWAY,
statusText: res.statusMessage,
}),
);
});
},
);
const timer = setTimeout(() => {
const error = new Error('WordPress 请求超时');
error.name = 'AbortError';
req.destroy(error);
}, timeoutMs || this.getTimeout());
req.on('error', reject);
req.on('close', () => clearTimeout(timer));
req.end(body);
});
}
private getRawNodeRequestHeaders(
headersInit: HeadersInit | undefined,
hostHeader: string,
) {
const headers = new Headers(headersInit);
const result: Record<string, string> = {};
headers.set('Host', hostHeader);
headers.forEach((value, key) => {
result[key] = value;
});
return result;
}
private getRawNodeRequestBody(body: RequestInit['body']) {
if (!body) return undefined;
if (typeof body === 'string') return body;
if (body instanceof URLSearchParams) return body.toString();
if (body instanceof ArrayBuffer) return Buffer.from(body);
if (ArrayBuffer.isView(body)) {
return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
}
return body as any;
}
private toResponseHeaders(headers: IncomingHttpHeaders) {
const responseHeaders = new Headers();
Object.entries(headers).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((item) => responseHeaders.append(key, item));
return;
}
if (value !== undefined) {
responseHeaders.set(key, value);
}
});
return responseHeaders;
}
private assertAuthContext(auth?: WordpressAuthContext) { private assertAuthContext(auth?: WordpressAuthContext) {
const hasToken = !!auth?.authorization; const hasToken = !!auth?.authorization;
const hasCookieLogin = !!auth?.cookie && !!auth?.nonce; const hasCookieLogin = !!auth?.cookie && !!auth?.nonce;
@ -703,6 +847,19 @@ export class WordpressService {
}; };
} }
private getWordpressHostHeader() {
const host = this.toolsService.toTrimmedString(
this.configService.get<string>('WORDPRESS_HOST_HEADER'),
);
if (!host) return '';
return host
.replace(/^https?:\/\//i, '')
.replace(/\/.*$/g, '')
.trim();
}
private getUrl(path: string, query?: Record<string, unknown>) { private getUrl(path: string, query?: Record<string, unknown>) {
const baseUrl = this.configService.get<string>('WORDPRESS_BASE_URL'); const baseUrl = this.configService.get<string>('WORDPRESS_BASE_URL');

View File

@ -1,3 +1,4 @@
import { createServer, type Server } from 'node:http';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { MarkdownService, ToolsService } from '../../src/common'; import { MarkdownService, ToolsService } from '../../src/common';
import { WordpressService } from '../../src/wordpress/wordpress.service'; import { WordpressService } from '../../src/wordpress/wordpress.service';
@ -179,6 +180,65 @@ describe('WordpressService theme config', () => {
}); });
}); });
it('uses configured WordPress host header for raw theme html requests', async () => {
const hosts: string[] = [];
const server = await new Promise<Server>((resolve) => {
const nextServer = createServer((request, response) => {
hosts.push(request.headers.host || '');
if (request.headers.host !== 'blog.kwitsukasa.top') {
response.writeHead(301, {
Location: 'http://127.0.0.1/',
});
response.end();
return;
}
if (request.url?.includes('rest_route=')) {
response.writeHead(200, {
'Content-Type': 'application/json',
});
response.end(JSON.stringify(rootPayload));
return;
}
response.writeHead(200, {
'Content-Type': 'text/html',
});
response.end(html);
});
nextServer.listen(0, '127.0.0.1', () => resolve(nextServer));
});
try {
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('测试服务启动失败');
}
service = new WordpressService(
new ConfigService({
WORDPRESS_BASE_URL: `http://127.0.0.1:${address.port}`,
WORDPRESS_HOST_HEADER: 'blog.kwitsukasa.top',
}),
markdownService,
new ToolsService(),
);
const config = await service.themeConfig();
expect(config.themeColor).toBe('#c3a1ed');
expect(config.site.title).toBe('KwiTsukasa的小站');
expect(hosts).toEqual([
'blog.kwitsukasa.top',
'blog.kwitsukasa.top',
]);
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it('renders markdown article content before saving to WordPress', async () => { it('renders markdown article content before saving to WordPress', async () => {
fetchSpy.mockImplementation(async (url: URL | RequestInfo, init?: any) => { fetchSpy.mockImplementation(async (url: URL | RequestInfo, init?: any) => {
const target = `${url}`; const target = `${url}`;