diff --git a/docker-compose.yml b/docker-compose.yml index 61b427a..a50bf7a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -101,6 +101,13 @@ services: build: context: ./docker/php restart: unless-stopped + # Safe here (unlike wordpress's own service): this is a plain shell loop, + # not php-fpm's master process, which needs to start as root so it can + # drop privileges to its www-data workers itself. Missed in the earlier + # root-hardening pass — found via a real bug: this container writing + # wp-cron-triggered uploads as root, silently blocking www-data-run + # wp-cli commands from writing new files into those same directories. + user: www-data depends_on: - wordpress entrypoint: ["/bin/sh", "/usr/local/bin/cron-entrypoint.sh"] diff --git a/docker/cron/entrypoint.sh b/docker/cron/entrypoint.sh index 01c8d84..6181fd2 100755 --- a/docker/cron/entrypoint.sh +++ b/docker/cron/entrypoint.sh @@ -7,7 +7,7 @@ set -eu echo "bookstore-core cron sidecar starting (60s interval)" while true; do - if ! wp cron event run --due-now --path=/var/www/html --allow-root 2>/tmp/cron-last-error.log; then + if ! wp cron event run --due-now --path=/var/www/html 2>/tmp/cron-last-error.log; then echo "cron run failed: $(cat /tmp/cron-last-error.log)" fi sleep 60 diff --git a/wp-content/plugins/bookstore-core/includes/Cli/Commands.php b/wp-content/plugins/bookstore-core/includes/Cli/Commands.php index 63beca7..57b8831 100644 --- a/wp-content/plugins/bookstore-core/includes/Cli/Commands.php +++ b/wp-content/plugins/bookstore-core/includes/Cli/Commands.php @@ -7,6 +7,7 @@ use Bookstore\Core\Catalog\Work; use Bookstore\Core\Import\GutenbergImporter; use Bookstore\Core\Import\SyntheticOfferGenerator; use Bookstore\Core\Integration\HardcoverAdapter; +use Bookstore\Core\Product\CoverSync; use Bookstore\Core\Product\ProductSync; use Bookstore\Core\Taxonomy\TagTaxonomies; @@ -190,4 +191,44 @@ class Commands \WP_CLI::success("Hardcover sync: {$matched} matched, {$missed} missed, {$processed} processed."); } + + /** + * Attaches a real product image from Open Library's Covers API to + * each Work's product (skips products that already have one). + * + * ## OPTIONS + * + * [--limit=] + * : Max works to process this run (default: all). + * + * [--delay-ms=] + * : Delay between lookups in milliseconds (default 1000) — stays + * comfortably under Open Library's stated 100-requests-per-5-minutes + * rate limit for search-based lookups. + * + * ## EXAMPLES + * + * wp bookstore sync-covers --limit=20 + * + * @subcommand sync-covers + */ + public function sync_covers($args, $assoc_args) + { + $limit = isset($assoc_args['limit']) ? (int) $assoc_args['limit'] : PHP_INT_MAX; + $delay_ms = (int) ($assoc_args['delay-ms'] ?? 1000); + + $result = CoverSync::sync_all($limit, $delay_ms, function (int $processed, int $attached, int $missed) { + if ($processed % 10 === 0) { + \WP_CLI::log(" processed {$processed} (attached {$attached}, missed {$missed})..."); + } + }); + + \WP_CLI::success(sprintf( + 'Covers: %d attached, %d missed, %d already had one, %d processed.', + $result['attached'], + $result['missed'], + $result['skipped'], + $result['processed'] + )); + } } diff --git a/wp-content/plugins/bookstore-core/includes/Import/GutenbergImporter.php b/wp-content/plugins/bookstore-core/includes/Import/GutenbergImporter.php index 093d6b4..382933c 100644 --- a/wp-content/plugins/bookstore-core/includes/Import/GutenbergImporter.php +++ b/wp-content/plugins/bookstore-core/includes/Import/GutenbergImporter.php @@ -56,7 +56,7 @@ class GutenbergImporter throw new \RuntimeException('PG catalog file is empty'); } $col = array_flip($header); - foreach (['Text#', 'Type', 'Issued', 'Title', 'Language', 'Authors', 'Subjects'] as $required) { + foreach (['Text#', 'Type', 'Issued', 'Title', 'Language', 'Authors', 'Subjects', 'LoCC'] as $required) { if (!isset($col[$required])) { fclose($handle); throw new \RuntimeException("PG catalog missing expected column: {$required}"); @@ -79,6 +79,9 @@ class GutenbergImporter if ($pg_id <= 0 || $title === '') { continue; } + if (!self::is_literature($row[$col['LoCC']] ?? '')) { + continue; // government documents, speeches, law, history — not "books" + } $isbn13 = self::synthetic_isbn13($pg_id); if (Isbn::exists($isbn13)) { @@ -132,6 +135,28 @@ class GutenbergImporter return $body . $check; } + /** + * LoCC (Library of Congress Classification) is real, structured data — + * verified against PG's actual catalog: novels consistently get a P* + * code (Language & Literature: PR English, PQ Romance languages, PS + * American, PZ fiction, etc.), while speeches/legal/historical + * documents get E/JK/KF/DA (History, Political Science, Law) and + * never a P code at all. A row can have several semicolon-separated + * LoCC codes; any one of them starting with P is enough to count as + * literature. Missing LoCC data is treated as "not a book" — stricter + * than necessary for a handful of edge cases, but matches what was + * actually asked for: no non-book documents in the catalog. + */ + private static function is_literature(string $locc_raw): bool + { + foreach (explode(';', $locc_raw) as $code) { + if (str_starts_with(ltrim($code), 'P')) { + return true; + } + } + return false; + } + /** * PG's Authors column lists every contributor for anthology/collection * works (sometimes dozens, semicolon-separated), which can run well diff --git a/wp-content/plugins/bookstore-core/includes/Integration/OpenLibraryAdapter.php b/wp-content/plugins/bookstore-core/includes/Integration/OpenLibraryAdapter.php new file mode 100644 index 0000000..ca21a7c --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Integration/OpenLibraryAdapter.php @@ -0,0 +1,126 @@ + $title, 'limit' => 5, 'fields' => 'cover_i,author_name']; + if ($author) { + $args['author'] = $author; + } + $url = self::SEARCH_URL . '?' . http_build_query($args); + + $response = self::get_with_retry($url); + if ($response === null) { + return null; + } + + $data = json_decode(wp_remote_retrieve_body($response), true); + foreach ($data['docs'] ?? [] as $doc) { + if (!empty($doc['cover_i'])) { + return (int) $doc['cover_i']; + } + } + return null; + } + + /** + * Retries on network errors and 5xx/429 — real, observed transient + * failures, not hypothetical. Does NOT retry a clean 200 that just + * isn't what we wanted (a legitimate "no cover"/"no match" result is + * not a glitch and retrying it would just waste requests). + */ + private static function get_with_retry(string $url, int $max_attempts = 3, int $retry_delay_ms = 500): ?array + { + for ($attempt = 1; $attempt <= $max_attempts; $attempt++) { + $response = wp_remote_get($url, ['timeout' => 20]); + if (!is_wp_error($response)) { + $code = wp_remote_retrieve_response_code($response); + if ($code === 200) { + return $response; + } + if ($code < 500 && $code !== 429) { + return null; // a definitive non-transient response (404, etc.) + } + } + if ($attempt < $max_attempts) { + usleep($retry_delay_ms * 1000); + } + } + return null; + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Product/CoverSync.php b/wp-content/plugins/bookstore-core/includes/Product/CoverSync.php new file mode 100644 index 0000000..df4b593 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Product/CoverSync.php @@ -0,0 +1,105 @@ +, which wouldn't show up in the shop loop, gallery, + * or structured data (those all read from a real attachment). Skips + * products that already have one, so re-runs are idempotent and cheap. + */ +class CoverSync +{ + /** + * @param callable|null $on_progress function(int $processed, int $attached, int $missed): void + * @return array{attached: int, missed: int, skipped: int, processed: int} + */ + public static function sync_all(int $limit, int $delay_ms, ?callable $on_progress = null): array + { + require_once ABSPATH . 'wp-admin/includes/image.php'; + + $attached = 0; + $missed = 0; + $skipped = 0; + $processed = 0; + $offset = 0; + $batch_size = 50; + + while ($processed < $limit) { + $works = Work::all_paginated($batch_size, $offset); + if (empty($works)) { + break; + } + + foreach ($works as $work) { + if ($processed >= $limit) { + break; + } + + $product_id = (int) ($work->wc_product_id ?? 0); + if (!$product_id) { + continue; // no product yet — run sync-products first + } + if (has_post_thumbnail($product_id)) { + $skipped++; + continue; + } + + $isbns = Isbn::isbns_for_work((int) $work->work_id); + $isbn13 = $isbns[0] ?? ''; + + $bytes = OpenLibraryAdapter::fetch_cover($isbn13, $work->title, $work->primary_author); + if ($bytes) { + if (self::attach_cover($product_id, $work->title, $bytes)) { + $attached++; + } else { + $missed++; + } + } else { + $missed++; + } + + $processed++; + OpenLibraryAdapter::throttle($delay_ms); + + if ($on_progress) { + $on_progress($processed, $attached, $missed); + } + } + + $offset += $batch_size; + } + + return ['attached' => $attached, 'missed' => $missed, 'skipped' => $skipped, 'processed' => $processed]; + } + + private static function attach_cover(int $product_id, string $title, string $bytes): bool + { + $filename = sanitize_file_name($title) . '-cover.jpg'; + $upload = wp_upload_bits($filename, null, $bytes); + if (!empty($upload['error'])) { + return false; + } + + $attachment_id = wp_insert_attachment([ + 'post_mime_type' => 'image/jpeg', + 'post_title' => $title . ' cover', + 'post_status' => 'inherit', + ], $upload['file'], $product_id); + + if (!$attachment_id || is_wp_error($attachment_id)) { + return false; + } + + $metadata = wp_generate_attachment_metadata($attachment_id, $upload['file']); + wp_update_attachment_metadata($attachment_id, $metadata); + + return (bool) set_post_thumbnail($product_id, $attachment_id); + } +}