mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-26 05:01:53 +08:00
Enhance architecture documentation and update project dependencies. Introduce new features for browser recording, page callables, and transform capabilities. Improve build scripts and permissions for better functionality.
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import type { UserAgentProfile } from '@/types/models';
|
||||
|
||||
export const BUILTIN_USER_AGENT_PROFILES: readonly UserAgentProfile[] = [
|
||||
{
|
||||
id: 'chrome-windows', name: 'Chrome / Windows', category: 'desktop', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
|
||||
},
|
||||
{
|
||||
id: 'chrome-macos', name: 'Chrome / macOS', category: 'desktop', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
|
||||
},
|
||||
{
|
||||
id: 'edge-windows', name: 'Edge / Windows', category: 'desktop', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0',
|
||||
},
|
||||
{
|
||||
id: 'firefox-windows', name: 'Firefox / Windows', category: 'desktop', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:139.0) Gecko/20100101 Firefox/139.0',
|
||||
},
|
||||
{
|
||||
id: 'firefox-linux', name: 'Firefox / Linux', category: 'desktop', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0',
|
||||
},
|
||||
{
|
||||
id: 'safari-macos', name: 'Safari / macOS', category: 'desktop', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15',
|
||||
},
|
||||
{
|
||||
id: 'safari-iphone', name: 'Safari / iPhone', category: 'mobile', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1',
|
||||
},
|
||||
{
|
||||
id: 'safari-ipad', name: 'Safari / iPad', category: 'mobile', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (iPad; CPU OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1',
|
||||
},
|
||||
{
|
||||
id: 'chrome-android', name: 'Chrome / Android', category: 'mobile', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 15; Pixel 9 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Mobile Safari/537.36',
|
||||
},
|
||||
{
|
||||
id: 'googlebot', name: 'Googlebot', category: 'bot', builtin: true,
|
||||
userAgent: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function getUserAgentProfiles(custom: UserAgentProfile[]): UserAgentProfile[] {
|
||||
return [...BUILTIN_USER_AGENT_PROFILES.map((profile) => ({ ...profile })), ...custom.map((profile) => ({ ...profile }))];
|
||||
}
|
||||
@@ -2,19 +2,55 @@ import { vi, describe, expect, it } from 'vitest';
|
||||
|
||||
vi.mock('wxt/browser', () => ({ browser: { declarativeNetRequest: {} } }));
|
||||
|
||||
import { buildUserAgentDnrRules } from './user-agent';
|
||||
import {
|
||||
buildUserAgentDnrRules, resolveUserAgent, userAgentHostname, validateUserAgent,
|
||||
} from './user-agent';
|
||||
import { BUILTIN_USER_AGENT_PROFILES } from './user-agent-profiles';
|
||||
import type { UserAgentAssignment, UserAgentProfile } from '@/types/models';
|
||||
|
||||
describe('User-Agent DNR rules', () => {
|
||||
it('normalizes domains and covers browser request resource types', () => {
|
||||
const [rule] = buildUserAgentDnrRules([{
|
||||
id: 'ua-1', name: 'Test', enabled: true, userAgent: 'Yakit-E2E/1.0', domains: ['https://*.example.test/path'],
|
||||
}]);
|
||||
expect(rule.condition.urlFilter).toBe('||example.test^');
|
||||
const assignment: UserAgentAssignment = {
|
||||
id: 'assignment-1', hostname: 'app.example.test', profileId: 'chrome-windows', createdAt: 1, updatedAt: 2,
|
||||
};
|
||||
|
||||
describe('User-Agent site assignments', () => {
|
||||
it('compiles one real request-header rule for each hostname', () => {
|
||||
const [rule] = buildUserAgentDnrRules([assignment]);
|
||||
expect(rule.condition.urlFilter).toBe('||app.example.test^');
|
||||
expect(rule.condition.resourceTypes).toContain('websocket');
|
||||
expect(rule.action).toMatchObject({ requestHeaders: [{ header: 'user-agent', operation: 'set', value: 'Yakit-E2E/1.0' }] });
|
||||
expect(rule.action).toMatchObject({ requestHeaders: [{ header: 'user-agent', operation: 'set' }] });
|
||||
});
|
||||
|
||||
it('ignores disabled rules', () => {
|
||||
expect(buildUserAgentDnrRules([{ id: 'x', name: 'X', enabled: false, userAgent: 'x', domains: [] }])).toHaveLength(0);
|
||||
it('deduplicates a hostname and ignores missing profiles', () => {
|
||||
const rules = buildUserAgentDnrRules([
|
||||
assignment,
|
||||
{ ...assignment, id: 'assignment-2', profileId: 'safari-iphone', updatedAt: 3 },
|
||||
{ ...assignment, id: 'missing', hostname: 'missing.example.test', profileId: 'missing' },
|
||||
]);
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(rules[0].action).toMatchObject({
|
||||
requestHeaders: [{ value: BUILTIN_USER_AGENT_PROFILES.find((item) => item.id === 'safari-iphone')!.userAgent }],
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves the effective profile for the current URL', () => {
|
||||
expect(resolveUserAgent('https://app.example.test/path', [assignment], [], 'Browser/Default'))
|
||||
.toMatchObject({ hostname: 'app.example.test', mode: 'override', profile: { id: 'chrome-windows' } });
|
||||
expect(resolveUserAgent('https://other.example.test/', [assignment], [], 'Browser/Default'))
|
||||
.toEqual({ hostname: 'other.example.test', mode: 'default', userAgent: 'Browser/Default' });
|
||||
});
|
||||
|
||||
it('supports custom profiles and rejects unsafe header values', () => {
|
||||
const custom: UserAgentProfile = {
|
||||
id: 'custom-1', name: 'Custom', userAgent: 'Yakit-Test/1.0', category: 'custom', builtin: false,
|
||||
};
|
||||
expect(buildUserAgentDnrRules([{ ...assignment, profileId: custom.id }], [custom])[0].action)
|
||||
.toMatchObject({ requestHeaders: [{ value: 'Yakit-Test/1.0' }] });
|
||||
expect(validateUserAgent(' Safe-UA/1.0 ')).toBe('Safe-UA/1.0');
|
||||
expect(() => validateUserAgent('Injected\r\nX-Test: yes')).toThrow('换行');
|
||||
});
|
||||
|
||||
it('only accepts HTTP(S) targets', () => {
|
||||
expect(userAgentHostname('http://127.0.0.1:8080/path')).toBe('127.0.0.1');
|
||||
expect(() => userAgentHostname('chrome://extensions')).toThrow('HTTP');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,48 +1,84 @@
|
||||
import { browser } from 'wxt/browser';
|
||||
import type { UserAgentRule } from '@/types/models';
|
||||
import type {
|
||||
UserAgentAssignment, UserAgentProfile, UserAgentResolution,
|
||||
} from '@/types/models';
|
||||
import { getUserAgentProfiles } from './user-agent-profiles';
|
||||
|
||||
const RULE_ID_BASE = 20_000;
|
||||
const MAX_UA_RULES = 5_000;
|
||||
const MAX_UA_ASSIGNMENTS = 5_000;
|
||||
|
||||
function domainFilter(domain: string): string {
|
||||
const normalized = domain.trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^\*\./, '');
|
||||
return normalized ? `||${normalized}^` : '*';
|
||||
function domainFilter(hostname: string): string {
|
||||
return `||${hostname}^`;
|
||||
}
|
||||
|
||||
export function buildUserAgentDnrRules(rules: UserAgentRule[]): Browser.declarativeNetRequest.Rule[] {
|
||||
const addRules: Browser.declarativeNetRequest.Rule[] = [];
|
||||
let nextRuleId = RULE_ID_BASE;
|
||||
for (const rule of rules.filter((item) => item.enabled)) {
|
||||
const domains = rule.domains.length > 0 ? [...new Set(rule.domains)] : [''];
|
||||
for (const domain of domains) {
|
||||
if (nextRuleId >= RULE_ID_BASE + MAX_UA_RULES) {
|
||||
throw new Error(`User-Agent 动态规则超过 ${MAX_UA_RULES} 条限制`);
|
||||
}
|
||||
addRules.push({
|
||||
id: nextRuleId,
|
||||
priority: nextRuleId - RULE_ID_BASE + 1,
|
||||
action: {
|
||||
type: 'modifyHeaders',
|
||||
requestHeaders: [{ header: 'user-agent', operation: 'set', value: rule.userAgent }],
|
||||
},
|
||||
condition: {
|
||||
urlFilter: domainFilter(domain),
|
||||
resourceTypes: [
|
||||
'main_frame', 'sub_frame', 'xmlhttprequest', 'script', 'image', 'stylesheet',
|
||||
'font', 'media', 'websocket', 'other',
|
||||
],
|
||||
},
|
||||
});
|
||||
nextRuleId += 1;
|
||||
}
|
||||
}
|
||||
return addRules;
|
||||
export function userAgentHostname(url: string): string {
|
||||
const parsed = new URL(url);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('User-Agent 只能应用到 HTTP(S) 页面');
|
||||
return parsed.hostname.toLowerCase();
|
||||
}
|
||||
|
||||
export async function applyUserAgentRules(rules: UserAgentRule[]): Promise<void> {
|
||||
export function validateUserAgent(value: string): string {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) throw new Error('User-Agent 不能为空');
|
||||
if (normalized.length > 1_024) throw new Error('User-Agent 不能超过 1024 个字符');
|
||||
if (/\r|\n/.test(normalized)) throw new Error('User-Agent 不能包含换行符');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveUserAgent(
|
||||
url: string,
|
||||
assignments: UserAgentAssignment[],
|
||||
customProfiles: UserAgentProfile[],
|
||||
browserDefault = globalThis.navigator?.userAgent || '',
|
||||
): UserAgentResolution {
|
||||
const hostname = userAgentHostname(url);
|
||||
const assignment = assignments.find((item) => item.hostname === hostname);
|
||||
const profile = assignment
|
||||
? getUserAgentProfiles(customProfiles).find((item) => item.id === assignment.profileId)
|
||||
: undefined;
|
||||
if (!assignment || !profile) return { hostname, mode: 'default', userAgent: browserDefault };
|
||||
return { hostname, mode: 'override', userAgent: profile.userAgent, profile, assignment };
|
||||
}
|
||||
|
||||
export function buildUserAgentDnrRules(
|
||||
assignments: UserAgentAssignment[],
|
||||
customProfiles: UserAgentProfile[] = [],
|
||||
): Browser.declarativeNetRequest.Rule[] {
|
||||
const profiles = new Map(getUserAgentProfiles(customProfiles).map((profile) => [profile.id, profile]));
|
||||
const uniqueAssignments = new Map(assignments.map((assignment) => [assignment.hostname, assignment]));
|
||||
const active = [...uniqueAssignments.values()]
|
||||
.filter((assignment) => profiles.has(assignment.profileId))
|
||||
.sort((left, right) => left.hostname.localeCompare(right.hostname));
|
||||
if (active.length > MAX_UA_ASSIGNMENTS) throw new Error(`User-Agent 站点绑定超过 ${MAX_UA_ASSIGNMENTS} 条限制`);
|
||||
return active.map((assignment, index) => {
|
||||
const profile = profiles.get(assignment.profileId)!;
|
||||
return {
|
||||
id: RULE_ID_BASE + index,
|
||||
priority: 1_000 + assignment.hostname.split('.').length,
|
||||
action: {
|
||||
type: 'modifyHeaders',
|
||||
requestHeaders: [{ header: 'user-agent', operation: 'set', value: validateUserAgent(profile.userAgent) }],
|
||||
},
|
||||
condition: {
|
||||
urlFilter: domainFilter(assignment.hostname),
|
||||
resourceTypes: [
|
||||
'main_frame', 'sub_frame', 'xmlhttprequest', 'script', 'image', 'stylesheet',
|
||||
'font', 'media', 'websocket', 'other',
|
||||
],
|
||||
},
|
||||
} satisfies Browser.declarativeNetRequest.Rule;
|
||||
});
|
||||
}
|
||||
|
||||
export async function applyUserAgentAssignments(
|
||||
assignments: UserAgentAssignment[],
|
||||
customProfiles: UserAgentProfile[] = [],
|
||||
): Promise<void> {
|
||||
const oldRuleIds = (await browser.declarativeNetRequest.getDynamicRules())
|
||||
.map((rule) => rule.id)
|
||||
.filter((id) => id >= RULE_ID_BASE && id < RULE_ID_BASE + 10_000);
|
||||
|
||||
await browser.declarativeNetRequest.updateDynamicRules({ removeRuleIds: oldRuleIds, addRules: buildUserAgentDnrRules(rules) });
|
||||
await browser.declarativeNetRequest.updateDynamicRules({
|
||||
removeRuleIds: oldRuleIds,
|
||||
addRules: buildUserAgentDnrRules(assignments, customProfiles),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user