fix: 修复WordPress主题源站抓取
This commit is contained in:
parent
1a008ea860
commit
490632c7a1
@ -12,6 +12,7 @@ MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_BUCKET=kt-template-online
|
||||
|
||||
WORDPRESS_BASE_URL=http://localhost
|
||||
WORDPRESS_HOST_HEADER=
|
||||
WORDPRESS_ADMIN_USERNAME=admin
|
||||
WORDPRESS_ADMIN_PASSWORD=
|
||||
WORDPRESS_TIMEOUT_MS=15000
|
||||
|
||||
@ -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 { ConfigService } from '@nestjs/config';
|
||||
import type { Request, Response as ExpressResponse } from 'express';
|
||||
@ -628,6 +631,13 @@ export class WordpressService {
|
||||
init: RequestInit = {},
|
||||
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 timer = setTimeout(
|
||||
() => controller.abort(),
|
||||
@ -635,7 +645,7 @@ export class WordpressService {
|
||||
);
|
||||
|
||||
try {
|
||||
return await fetch(this.getUrl(path), {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
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) {
|
||||
const hasToken = !!auth?.authorization;
|
||||
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>) {
|
||||
const baseUrl = this.configService.get<string>('WORDPRESS_BASE_URL');
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MarkdownService, ToolsService } from '../../src/common';
|
||||
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 () => {
|
||||
fetchSpy.mockImplementation(async (url: URL | RequestInfo, init?: any) => {
|
||||
const target = `${url}`;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user