diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fb84a49..39f440b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Setup PHP
uses: shivammathur/setup-php@v2
@@ -37,7 +37,10 @@ jobs:
run: composer install --no-interaction --prefer-dist --no-progress
- name: Lint PHP
- run: php -l proxy.php
+ run: |
+ for file in ./*.php; do
+ php -l "$file"
+ done
- name: Validate camera data
run: |
@@ -62,8 +65,47 @@ jobs:
echo "Validated " . count($data) . " cameras.\n";
'
- - name: Smoke-check UI anchors
+ - name: Validate sitemap
+ run: |
+ php sitemap.php > /tmp/camera-sitemap.xml
+ php -r '
+ $data = json_decode(file_get_contents("data-camera.json"), true, 512, JSON_THROW_ON_ERROR);
+ $xml = simplexml_load_file("/tmp/camera-sitemap.xml");
+ if ($xml === false) {
+ fwrite(STDERR, "Generated sitemap is invalid XML.\n");
+ exit(1);
+ }
+ $expected = count($data) + 1;
+ $actual = count($xml->url);
+ if ($actual !== $expected) {
+ fwrite(STDERR, "Expected {$expected} sitemap URLs, got {$actual}.\n");
+ exit(1);
+ }
+ echo "Validated {$actual} sitemap URLs.\n";
+ '
+ grep -q 'https://camera-hcm.eplus.dev/sitemap.php' sitemap.xml
+ grep -q 'Sitemap: https://camera-hcm.eplus.dev/sitemap.xml' robots.txt
+
+ - name: Smoke-check SEO anchors
run: |
grep -q 'id="cameraGrid"' index.html
grep -q 'id="searchInput"' index.html
- grep -q 'proxy.php?id=' index.html
+ grep -q 'rel="canonical" href="https://camera-hcm.eplus.dev/"' index.html
+ grep -q 'application/ld+json' index.html
+ grep -q 'camera.php?id=' index.html
+ grep -q 'max-image-preview:large' index.html
+ grep -q 'favicon.svg' index.html
+ grep -q 'manifest.webmanifest' index.html
+
+ - name: HTTP smoke test
+ run: |
+ FIRST_ID="$(php -r '$data=json_decode(file_get_contents("data-camera.json"), true, 512, JSON_THROW_ON_ERROR); echo $data[0]["CamId"];')"
+ php -S 127.0.0.1:8080 > /tmp/camera-php-server.log 2>&1 &
+ SERVER_PID=$!
+ trap 'kill "$SERVER_PID"' EXIT
+ sleep 1
+
+ curl -fsS "http://127.0.0.1:8080/" | grep -q 'Camera giao thông TP.HCM'
+ curl -fsS "http://127.0.0.1:8080/camera.php?id=$FIRST_ID" | grep -q 'https://camera-hcm.eplus.dev/camera.php?id='
+ curl -fsS "http://127.0.0.1:8080/camera.php?id=$FIRST_ID" | grep -q 'application/ld+json'
+ curl -fsS "http://127.0.0.1:8080/sitemap.php" | grep -q '
+
+The project exposes crawlable resources in addition to the JavaScript camera directory:
+
+- `camera.php?id={CamId}` — server-rendered canonical page for each camera.
+- `sitemap.xml` — sitemap index.
+- `sitemap.php` — dynamic sitemap containing the homepage and all camera detail pages.
+- `robots.txt` — crawler directives and sitemap discovery.
+- `llms.txt` — concise AI/LLM discovery file.
+- `agents.md` — machine-agent guidance and data semantics.
+- `manifest.webmanifest` and `favicon.svg` — app/site identity metadata.
+
+The homepage and detail pages include canonical URLs, social metadata and Schema.org JSON-LD.
diff --git a/agents.md b/agents.md
new file mode 100644
index 0000000..5aa0119
--- /dev/null
+++ b/agents.md
@@ -0,0 +1,31 @@
+# Agent guidance — Camera giao thông TP.HCM
+
+Canonical origin: https://camera-hcm.eplus.dev/
+
+## Purpose
+
+This site helps users find and view public traffic cameras in Ho Chi Minh City by camera name and district/area.
+
+## Preferred discovery flow
+
+1. Use /sitemap.xml or /sitemap.php to discover canonical camera detail pages.
+2. Use /data-camera.json for the camera catalog.
+3. Use /camera.php?id={CamId} for a human-readable, server-rendered camera page.
+4. Treat /proxy.php?id={CamId} as a live image endpoint, not as a stable text document.
+
+## Data semantics
+
+- CamId: stable camera identifier used by this site.
+- CamName: human-readable camera/intersection name.
+- Disctrict: source field containing district/area information. The misspelling is retained for backward compatibility.
+- Snapshot images are time-sensitive and may fail temporarily.
+
+## Crawling
+
+- Respect robots.txt.
+- Prefer canonical URLs from sitemap and rel=canonical.
+- Avoid generating or indexing arbitrary pagination/search URL combinations when a canonical camera detail page is available.
+
+## Attribution
+
+Site maintained by ePlus.DEV. Camera imagery depends on the Ho Chi Minh City traffic information source.
diff --git a/camera.php b/camera.php
new file mode 100644
index 0000000..5b66527
--- /dev/null
+++ b/camera.php
@@ -0,0 +1,402 @@
+getMessage());
+}
+
+$camera = null;
+foreach ($data as $item) {
+ if (($item['CamId'] ?? null) === $cameraId) {
+ $camera = $item;
+ break;
+ }
+}
+
+if (!$camera) {
+ http_response_code(404);
+ header('X-Robots-Tag: noindex, nofollow');
+ $pageTitle = 'Không tìm thấy camera | ePlus.DEV';
+ $pageDescription = 'Camera giao thông bạn yêu cầu không tồn tại hoặc đã được gỡ.';
+ $canonical = BASE_URL . '/';
+ $cameraName = 'Không tìm thấy camera';
+ $district = 'TP.HCM';
+ $imageUrl = '';
+ $related = [];
+} else {
+ $cameraName = trim((string) $camera['CamName']);
+ $district = cameraDistrict($camera);
+ $pageTitle = 'Camera ' . $cameraName . ' – ' . $district . ' | TP.HCM';
+ $pageDescription = 'Xem camera giao thông ' . $cameraName . ' tại ' . $district . ', TP.HCM. Hình ảnh camera được cập nhật tự động để tiện theo dõi tình hình giao thông.';
+ $canonical = BASE_URL . '/camera.php?id=' . rawurlencode($cameraId);
+ $imageUrl = BASE_URL . '/proxy.php?id=' . rawurlencode($cameraId);
+
+ $related = array_values(array_filter($data, static function (array $item) use ($cameraId, $district): bool {
+ return ($item['CamId'] ?? '') !== $cameraId
+ && cameraDistrict($item) === $district
+ && !empty($item['CamName'])
+ && !empty($item['CamId']);
+ }));
+ $related = array_slice($related, 0, 6);
+}
+
+$schema = $camera ? [
+ '@context' => 'https://schema.org',
+ '@type' => 'WebPage',
+ 'name' => $pageTitle,
+ 'description' => $pageDescription,
+ 'url' => $canonical,
+ 'inLanguage' => 'vi-VN',
+ 'isPartOf' => [
+ '@type' => 'WebSite',
+ 'name' => 'Camera giao thông TP.HCM',
+ 'url' => BASE_URL . '/',
+ ],
+ 'about' => [
+ '@type' => 'Place',
+ 'name' => $cameraName,
+ 'address' => [
+ '@type' => 'PostalAddress',
+ 'addressLocality' => 'Hồ Chí Minh',
+ 'addressRegion' => $district,
+ 'addressCountry' => 'VN',
+ ],
+ ],
+ 'primaryImageOfPage' => [
+ '@type' => 'ImageObject',
+ 'contentUrl' => $imageUrl,
+ 'name' => 'Camera ' . $cameraName,
+ ],
+] : null;
+
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ = h($pageTitle) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Camera giao thông TP.HCM
+
= h($cameraName) ?>
+
= h($pageDescription) ?>
+
+ = h($district) ?>
+ Mã camera: = h(strtoupper(substr($cameraId, -6))) ?>
+ Tự làm mới sau 10 giây
+
+
+ Hình ảnh phụ thuộc vào nguồn camera giao thông TP.HCM và có thể tạm thời không khả dụng.
+ Trang sẽ tự làm mới ảnh khi bạn đang mở tab này.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/favicon.svg b/favicon.svg
new file mode 100644
index 0000000..896eeb7
--- /dev/null
+++ b/favicon.svg
@@ -0,0 +1,12 @@
+
diff --git a/index.html b/index.html
index aa872ce..ddc5167 100644
--- a/index.html
+++ b/index.html
@@ -5,8 +5,45 @@
-
- Camera giao thông TP.HCM
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Camera giao thông TP.HCM trực tuyến | ePlus.DEV
+