Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions app/core/service/PackageSearchService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ export class PackageSearchService extends AbstractService {
_npmUser: latestManifest?._npmUser,
// 最新版本发布信息
publish_time: latestManifest?.publish_time,
// 最新版本的 deprecated 信息
deprecated: latestManifest?.deprecated as string | undefined,
Comment on lines +127 to +128
Copy link

Copilot AI Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment is in Chinese while the codebase appears to use English for comments elsewhere in this file (lines 131, 348-353). Consider translating to English: '// Latest version deprecated information' to maintain consistency.

Copilot uses AI. Check for mistakes.
};

// http://npmmirror.com/package/npm/files/lib/utils/format-search-stream.js#L147-L148
Expand Down Expand Up @@ -157,6 +159,7 @@ export class PackageSearchService extends AbstractService {
text,
scoreEffect: 0.25,
});
const filterQueries = this._buildFilterQueries();

const res = await this.searchRepository.searchPackage({
body: {
Expand All @@ -169,6 +172,7 @@ export class PackageSearchService extends AbstractService {
bool: {
should: matchQueries,
minimum_should_match: matchQueries.length > 0 ? 1 : 0,
filter: filterQueries,
},
},
script_score: scriptScore,
Expand Down Expand Up @@ -303,4 +307,74 @@ export class PackageSearchService extends AbstractService {
},
};
}

private _buildFilterQueries() {
// oxlint-disable-next-line typescript-eslint/no-explicit-any
Copy link

Copilot AI Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The oxlint-disable comment uses an incorrect rule name. According to the .oxlintrc.json configuration, the correct rule name should be @typescript-eslint/no-explicit-any (with @ prefix), not typescript-eslint/no-explicit-any.

Suggested change
// oxlint-disable-next-line typescript-eslint/no-explicit-any
// oxlint-disable-next-line @typescript-eslint/no-explicit-any

Copilot uses AI. Check for mistakes.
const filters: any[] = [];

// Filter deprecated packages
const excludeDeprecated = this.config.cnpmcore.searchExcludeDeprecated ?? true;
if (excludeDeprecated) {
filters.push({
bool: {
must_not: {
exists: {
field: 'package.deprecated',
},
},
},
});
}

// Filter newly published packages
const minAge = this.config.cnpmcore.searchPackageMinAge;
if (minAge) {
const minAgeMs = this._parseTimeString(minAge);
if (minAgeMs > 0) {
const thresholdDate = new Date(Date.now() - minAgeMs);
filters.push({
range: {
'package.date': {
lte: thresholdDate.toISOString(),
},
},
});
}
}

return filters;
}

/**
* Parse time string to milliseconds
* Supports formats: '2w' (weeks), '14d' (days), '336h' (hours)
* @param timeStr - time string like '2w', '14d', '336h'
* @returns milliseconds, or 0 if invalid
*/
private _parseTimeString(timeStr: string): number {
if (!timeStr) return 0;

const match = timeStr.match(/^(\d+)([hdw])$/);
if (!match) {
this.logger.warn(
'[PackageSearchService._parseTimeString] Invalid time format: %s, expected format like "2w", "14d", or "336h"',
timeStr
);
return 0;
}

const value = parseInt(match[1], 10);
const unit = match[2];

switch (unit) {
case 'h': // hours
return value * 60 * 60 * 1000;
case 'd': // days
return value * 24 * 60 * 60 * 1000;
case 'w': // weeks
return value * 7 * 24 * 60 * 60 * 1000;
default:
return 0;
}
}
}
14 changes: 14 additions & 0 deletions app/port/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,18 @@ export interface CnpmcoreConfig {
database: {
type: DATABASE_TYPE | string;
};

/**
* search package minimum age filter
* packages published within this time range will be excluded from search results
* support format: '2w' (weeks), '14d' (days), '336h' (hours)
* default is empty string (no filter)
*/
searchPackageMinAge?: string;

/**
* exclude deprecated packages from search results
* default is true
*/
searchExcludeDeprecated?: boolean;
}
3 changes: 2 additions & 1 deletion app/repository/SearchRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export type SearchJSONPickKey =
| 'license'
| 'maintainers'
| 'dist-tags'
| '_source_registry_name';
| '_source_registry_name'
| 'deprecated';

export type SearchMappingType = Pick<PackageManifestType, SearchJSONPickKey> &
CnpmcorePatchInfo & {
Expand Down
2 changes: 2 additions & 0 deletions config/config.default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@
database: {
type: database.type,
},
searchPackageMinAge: env('CNPMCORE_CONFIG_SEARCH_PACKAGE_MIN_AGE', 'string', ''),
searchExcludeDeprecated: env('CNPMCORE_CONFIG_SEARCH_EXCLUDE_DEPRECATED', 'boolean', true),
};

export interface NFSConfig {
Expand All @@ -87,7 +89,7 @@

export type Config = PartialEggConfig & { nfs: NFSConfig };

export default function startConfig(appInfo: EggAppConfig): Config {

Check failure on line 92 in config/config.default.ts

View workflow job for this annotation

GitHub Actions / test on mysql (node@20, shard@1/3)

Return type annotation circularly references itself.

Check failure on line 92 in config/config.default.ts

View workflow job for this annotation

GitHub Actions / test on mysql (node@20, shard@1/3)

Return type annotation circularly references itself.

Check failure on line 92 in config/config.default.ts

View workflow job for this annotation

GitHub Actions / test on postgresql (node@20, shard@1/3)

Return type annotation circularly references itself.

Check failure on line 92 in config/config.default.ts

View workflow job for this annotation

GitHub Actions / test on postgresql (node@20, shard@1/3)

Return type annotation circularly references itself.
const config = {} as Config;

config.keys = env('CNPMCORE_EGG_KEYS', 'string', randomUUID());
Expand Down
160 changes: 160 additions & 0 deletions test/port/controller/package/SearchPackageController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,166 @@ describe('test/port/controller/package/SearchPackageController.test.ts', () => {
assert.equal(res.body.objects[0].package.name, 'example');
assert.equal(res.body.total, 1);
});

it('should filter deprecated packages by default', async () => {
let capturedQuery: any;
mockES.add(
{
method: 'POST',
path: `/${app.config.cnpmcore.elasticsearchIndex}/_search`,
},
(params: any) => {
capturedQuery = params.body;
return {
hits: {
total: { value: 0, relation: 'eq' },
hits: [],
},
};
}
);
await app
.httpRequest()
.get('/-/v1/search?text=example&from=0&size=1')
.expect(200);

// Verify that the query includes deprecated filter
assert(capturedQuery);
assert(capturedQuery.query.function_score.query.bool.filter);
const filters = capturedQuery.query.function_score.query.bool.filter;
const deprecatedFilter = filters.find((f: any) =>
f.bool?.must_not?.exists?.field === 'package.deprecated'
);
assert(deprecatedFilter, 'Should include deprecated filter');
});

it('should not filter deprecated packages when searchExcludeDeprecated is false', async () => {
mock(app.config.cnpmcore, 'searchExcludeDeprecated', false);
let capturedQuery: any;
mockES.add(
{
method: 'POST',
path: `/${app.config.cnpmcore.elasticsearchIndex}/_search`,
},
(params: any) => {
capturedQuery = params.body;
return {
hits: {
total: { value: 1, relation: 'eq' },
hits: [
{
_source: {
downloads: { all: 0 },
package: {
name: 'deprecated-package',
deprecated: 'This package is deprecated',
},
},
},
],
},
};
}
);
await app
.httpRequest()
.get('/-/v1/search?text=example&from=0&size=1')
.expect(200);

// Verify that the query does not include deprecated filter
const filters = capturedQuery.query.function_score.query.bool.filter || [];
const deprecatedFilter = filters.find((f: any) =>
f.bool?.must_not?.exists?.field === 'package.deprecated'
);
assert(!deprecatedFilter, 'Should not include deprecated filter');
});

it('should filter newly published packages when searchPackageMinAge is set', async () => {
mock(app.config.cnpmcore, 'searchPackageMinAge', '2w');
let capturedQuery: any;
mockES.add(
{
method: 'POST',
path: `/${app.config.cnpmcore.elasticsearchIndex}/_search`,
},
(params: any) => {
capturedQuery = params.body;
return {
hits: {
total: { value: 0, relation: 'eq' },
hits: [],
},
};
}
);
await app
.httpRequest()
.get('/-/v1/search?text=example&from=0&size=1')
.expect(200);

// Verify that the query includes date range filter
assert(capturedQuery);
const filters = capturedQuery.query.function_score.query.bool.filter;
const dateFilter = filters.find((f: any) => f.range?.['package.date']);
assert(dateFilter, 'Should include date range filter');
assert(dateFilter.range['package.date'].lte, 'Should have lte date threshold');
});

it('should parse time string correctly in hours', async () => {
mock(app.config.cnpmcore, 'searchPackageMinAge', '48h');
let capturedQuery: any;
mockES.add(
{
method: 'POST',
path: `/${app.config.cnpmcore.elasticsearchIndex}/_search`,
},
(params: any) => {
capturedQuery = params.body;
return {
hits: {
total: { value: 0, relation: 'eq' },
hits: [],
},
};
}
);
await app
.httpRequest()
.get('/-/v1/search?text=example&from=0&size=1')
.expect(200);

const filters = capturedQuery.query.function_score.query.bool.filter;
const dateFilter = filters.find((f: any) => f.range?.['package.date']);
assert(dateFilter, 'Should include date range filter for hours');
});

it('should parse time string correctly in days', async () => {
mock(app.config.cnpmcore, 'searchPackageMinAge', '14d');
let capturedQuery: any;
mockES.add(
{
method: 'POST',
path: `/${app.config.cnpmcore.elasticsearchIndex}/_search`,
},
(params: any) => {
capturedQuery = params.body;
return {
hits: {
total: { value: 0, relation: 'eq' },
hits: [],
},
};
}
);
await app
.httpRequest()
.get('/-/v1/search?text=example&from=0&size=1')
.expect(200);

const filters = capturedQuery.query.function_score.query.bool.filter;
const dateFilter = filters.find((f: any) => f.range?.['package.date']);
assert(dateFilter, 'Should include date range filter for days');
});
});

describe('[PUT /-/v1/search/sync/:fullname] sync()', async () => {
Expand Down
Loading