diff --git a/CHANGELOG.md b/CHANGELOG.md index 31d1d2c..8f2a257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/spec/v2.0.0.ht --- +## [2.15.0] — 2026-07-07 + +MQA-Qualitäts-Scoring **Phase 3a**: optionale URL-Erreichbarkeitsprüfung. + +### ✨ Added — URL-Erreichbarkeit (opt-in) +- Neue Einstellung **„URL-Erreichbarkeit prüfen"** (Bereich *Qualitätsprüfung (MQA)*, + standardmäßig deaktiviert). Ist sie aktiv, prüft das Plugin `dcat:accessURL` und + `dcat:downloadURL` beim Speichern per **HTTP HEAD** (mit GET-Fallback bei 405/501) auf + Erreichbarkeit (Statuscode 200–399). +- Ergebnisse werden **24 Stunden** als Transient zwischengespeichert (Cache-Key je URL), + sodass wiederholte Speichervorgänge keine erneuten Requests auslösen. +- Damit steigt das bewertbare Maximum bei aktivierter Prüfung von ~295 auf **~375 von 405**. + Es verbleibt nur die **DCAT-AP-SHACL-Konformität** (30 P) als „nicht bewertet". + +### 🔒 Datenschutz/Performance +- Die Prüfung ist **opt-in** und sendet ausgehende Anfragen ausschließlich an die im Datensatz + hinterlegten URLs (kein Fremddienst). Kurzer Timeout (5 s) + 24h-Cache. + +### 🧾 i18n +- 4 neue UI-Strings; `en_US.mo` neu kompiliert. + +### ✅ Tests +- Erreichbarkeitslogik: Cache-Hit (kein Request) und 2xx→erreichbar (mit Caching). + +--- + ## [2.14.0] — 2026-07-07 MQA-Qualitäts-Scoring **Phase 2**: Vokabular-Prüfungen (offline). Siehe `docs/MQA-KONZEPT.md`. diff --git a/README.md b/README.md index 18ca9bb..1b46ea0 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ![PHP Version](https://img.shields.io/badge/PHP-%3E%3D%208.1-8892BF?style=flat-square&logo=php&logoColor=white) ![WordPress](https://img.shields.io/badge/WordPress-compatible-21759B?style=flat-square&logo=wordpress&logoColor=white) ![DCAT-AP](https://img.shields.io/badge/DCAT--AP-3.0-brightgreen?style=flat-square) -![Version](https://img.shields.io/badge/Version-2.14.0-brightgreen?style=flat-square) +![Version](https://img.shields.io/badge/Version-2.15.0-brightgreen?style=flat-square) ![PRs Welcome](https://img.shields.io/badge/PRs-willkommen-brightgreen?style=flat-square) 📖 [Dokumentation](DOCUMENTATION.md) · 📐 [Technische Spezifikation](TECHNICAL-SPEC.md) · 📝 [Changelog](CHANGELOG.md) · 🛡️ [Security](SECURITY.md) · ⚖️ [Lizenz](LICENSE) diff --git a/includes/class-quality.php b/includes/class-quality.php index 84dc18a..6756788 100644 --- a/includes/class-quality.php +++ b/includes/class-quality.php @@ -200,10 +200,23 @@ private static function evaluate_metric( array $metric, \WP_Post $post ): ?bool return self::check_vocab_metric( (string) $metric['check'], $post ); } - // Erreichbarkeits- und SHACL-Prüfungen folgen in Phase 3. + if ( 'reachable' === $type && self::url_checks_enabled() ) { + return self::check_reachable_metric( (string) $metric['check'], $post ); + } + + // SHACL (und Erreichbarkeit bei deaktivierter Einstellung) folgt in Phase 3+. return null; } + /** + * Ist die opt-in-URL-Erreichbarkeitsprüfung aktiviert? + * + * @return bool + */ + private static function url_checks_enabled(): bool { + return class_exists( 'ODW_Settings' ) && (bool) ODW_Settings::get( 'mqa_check_urls' ); + } + /** * „Ist die Eigenschaft gesetzt?"-Prüfung je Metrik. * @@ -361,6 +374,58 @@ private static function license_in_vocab( string $uri ): bool { return isset( $extended[ $uri ] ); } + /** + * „Ist die referenzierte URL erreichbar?"-Prüfung (MQA Phase 3, opt-in). + * + * @param string $check Check-Schlüssel (access_url | download_url). + * @param \WP_Post $post Dataset post object. + * @return bool True wenn die URL per HTTP-HEAD einen 2xx/3xx-Status liefert. + */ + private static function check_reachable_metric( string $check, \WP_Post $post ): bool { + $field = 'download_url' === $check ? 'odw_download_url' : 'odw_access_url'; + $url = trim( (string) carbon_get_post_meta( $post->ID, $field ) ); + + if ( '' === $url || ! preg_match( '#^https?://#i', $url ) ) { + return false; + } + + return self::url_is_reachable( $url ); + } + + /** + * Prüft die Erreichbarkeit einer URL per HTTP HEAD (mit GET-Fallback), 24h gecacht. + * + * @param string $url Zu prüfende URL. + * @return bool True bei Statuscode 200–399. + */ + private static function url_is_reachable( string $url ): bool { + $cache_key = 'odw_mqa_reach_' . md5( $url ); + $cached = get_transient( $cache_key ); + if ( '1' === $cached || '0' === $cached ) { + return '1' === $cached; + } + + $args = array( + 'timeout' => 5, + 'redirection' => 3, + 'user-agent' => 'OpenDataWizard-MQA/1.0', + ); + + $response = wp_remote_head( $url, $args ); + $code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response ); + + // Manche Server lehnen HEAD ab (405/501) — dann ein leichtgewichtiges GET versuchen. + if ( 405 === $code || 501 === $code || 0 === $code ) { + $response = wp_remote_get( $url, $args ); + $code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response ); + } + + $ok = $code >= 200 && $code < 400; + set_transient( $cache_key, $ok ? '1' : '0', DAY_IN_SECONDS ); + + return $ok; + } + /** * Ermittelt die MQA-Bewertungsstufe aus erreichten/bewertbaren Punkten. * Die MQA-Schwellen (351/221/121 von 405) werden proportional auf das @@ -587,7 +652,7 @@ public static function render_meta_box( \WP_Post $post ): void { echo esc_html( sprintf( /* translators: %d: number of points not yet assessed */ - __( '%d Punkte werden derzeit nicht bewertet (URL-Erreichbarkeit und DCAT-AP-SHACL-Prüfung folgen).', 'open-data-wizard' ), + __( '%d Punkte konnten nicht automatisch bewertet werden (in der Tabelle mit „–" markiert).', 'open-data-wizard' ), $not_assessed ) ); diff --git a/includes/class-settings.php b/includes/class-settings.php index 2066702..db96203 100644 --- a/includes/class-settings.php +++ b/includes/class-settings.php @@ -166,6 +166,16 @@ static function (): void { add_settings_field( 'cache_ttl', __( 'Cache-Laufzeit (Sekunden)', 'open-data-wizard' ), array( self::class, 'field_cache_ttl' ), 'odw-settings', 'odw_section_api' ); + // --- Qualität (MQA) --- + add_settings_section( + 'odw_section_quality', + __( 'Qualitätsprüfung (MQA)', 'open-data-wizard' ), + null, + 'odw-settings' + ); + + add_settings_field( 'mqa_check_urls', __( 'URL-Erreichbarkeit prüfen', 'open-data-wizard' ), array( self::class, 'field_mqa_check_urls' ), 'odw-settings', 'odw_section_quality' ); + // --- Deinstallation --- add_settings_section( 'odw_section_uninstall', @@ -290,6 +300,27 @@ class="small-text" + +

+ +

+ '', 'cache_ttl' => 300, 'delete_on_uninstall' => '0', + 'mqa_check_urls' => '0', ); } } diff --git a/languages/open-data-wizard-en_US.mo b/languages/open-data-wizard-en_US.mo index 8dadac7..83e1647 100644 Binary files a/languages/open-data-wizard-en_US.mo and b/languages/open-data-wizard-en_US.mo differ diff --git a/languages/open-data-wizard-en_US.po b/languages/open-data-wizard-en_US.po index e7cf5ab..bce0e5a 100644 --- a/languages/open-data-wizard-en_US.po +++ b/languages/open-data-wizard-en_US.po @@ -18,6 +18,9 @@ msgstr " created and are then available for editing." msgid "%1$d von %2$d Pflichtangaben ausgefüllt" msgstr "%1$d of %2$d required fields completed" +msgid "%d Punkte konnten nicht automatisch bewertet werden (in der Tabelle mit „–\" markiert)." +msgstr "%d points could not be assessed automatically (marked with “–” in the table)." + msgid "%d Punkte werden derzeit nicht bewertet (URL-Erreichbarkeit und DCAT-AP-SHACL-Prüfung folgen)." msgstr "%d points are currently not assessed (URL reachability and DCAT-AP SHACL checks are coming)." @@ -1035,6 +1038,9 @@ msgstr "Keywords (dcat:keyword)" msgid "Sekunden" msgstr "Seconds" +msgid "Sendet ausgehende Anfragen an die angegebenen Datensatz-URLs. Ergebnisse werden 24 Stunden zwischengespeichert. Standardmäßig deaktiviert." +msgstr "Sends outbound requests to the given dataset URLs. Results are cached for 24 hours. Disabled by default." + msgid "Shortcode" msgstr "Shortcode" @@ -1143,6 +1149,9 @@ msgstr "URL of the project website or data portal with further information about msgid "URL zur Beschreibung des Qualitätssicherungs-Prozesses (optional)." msgstr "URL describing the quality-assurance process (optional)." +msgid "URL-Erreichbarkeit prüfen" +msgstr "Check URL reachability" + msgid "Um welchen Typ von Datensatz handelt es sich?" msgstr "What type of dataset is this?" @@ -1413,6 +1422,9 @@ msgstr "Access & further discoverability" msgid "Zugriff verweigert." msgstr "Access denied." +msgid "Zugriffs- und Download-URLs beim Speichern per HTTP-HEAD auf Erreichbarkeit prüfen (MQA-Zugänglichkeit, +80 Punkte)." +msgstr "Check access and download URLs for reachability via HTTP HEAD on save (MQA accessibility, +80 points)." + msgid "Zugriffs-Klassifikation des Datensatzes. Beispiel: Öffentlich, Eingeschränkt, Nicht öffentlich" msgstr "Access classification of the dataset. Example: Public, Restricted, Non-public" diff --git a/open-data-wizard.php b/open-data-wizard.php index f082152..7eb9357 100644 --- a/open-data-wizard.php +++ b/open-data-wizard.php @@ -3,7 +3,7 @@ * Plugin Name: Open Data Wizard * Plugin URI: https://github.com/daimpad/OpenDataWizard * Description: DCAT-AP 3.0 konforme Open Data Metadatenverwaltung für WordPress. Bereitstellung als maschinenlesbarer JSON-LD-Endpoint für offene Daten. - * Version: 2.14.0 + * Version: 2.15.0 * Requires at least: 6.4 * Requires PHP: 8.1 * Author: nozilla @@ -26,7 +26,7 @@ exit; } -define( 'ODW_VERSION', '2.14.0' ); +define( 'ODW_VERSION', '2.15.0' ); define( 'ODW_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'ODW_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'ODW_PLUGIN_FILE', __FILE__ ); diff --git a/tests/test-quality.php b/tests/test-quality.php index 8121d26..d46972a 100644 --- a/tests/test-quality.php +++ b/tests/test-quality.php @@ -403,4 +403,50 @@ public function test_append_to_jsonld_skips_when_no_data(): void { $this->assertArrayNotHasKey( 'odw:qualityScore', $result ); } + + // ------------------------------------------------------------------------- + // url_is_reachable() — MQA Phase 3 (opt-in reachability) + // ------------------------------------------------------------------------- + + /** + * Invokes the private static url_is_reachable() helper. + * + * @param string $url URL to check. + * @return bool + */ + private function call_url_is_reachable( string $url ): bool { + $ref = new \ReflectionMethod( 'ODW_Quality', 'url_is_reachable' ); + $ref->setAccessible( true ); + return (bool) $ref->invoke( null, $url ); + } + + /** + * A cached negative result short-circuits without a network request. + */ + public function test_url_is_reachable_uses_cached_result(): void { + $this->load_class(); + + \WP_Mock::userFunction( 'get_transient' )->andReturn( '0' ); + + $this->assertFalse( $this->call_url_is_reachable( 'https://example.com/data.csv' ) ); + } + + /** + * A 200 HEAD response marks the URL reachable and caches the result. + */ + public function test_url_is_reachable_maps_2xx_to_true(): void { + $this->load_class(); + + if ( ! defined( 'DAY_IN_SECONDS' ) ) { + define( 'DAY_IN_SECONDS', 86400 ); + } + + \WP_Mock::userFunction( 'get_transient' )->andReturn( false ); + \WP_Mock::userFunction( 'wp_remote_head' )->andReturn( array( 'response' => array( 'code' => 200 ) ) ); + \WP_Mock::userFunction( 'is_wp_error' )->andReturn( false ); + \WP_Mock::userFunction( 'wp_remote_retrieve_response_code' )->andReturn( 200 ); + \WP_Mock::userFunction( 'set_transient' )->once()->andReturn( true ); + + $this->assertTrue( $this->call_url_is_reachable( 'https://example.com/data.csv' ) ); + } }