Product cover images via Open Library; Gutenberg literature filter; cron root fix
Cover sync (OpenLibraryAdapter + CoverSync + sync-covers command): fetches a real cover from Open Library's free, keyless, explicitly-licensed-for- this-use Covers API and attaches it as a genuine Media Library attachment (not a hotlinked <img> — WooCommerce's shop loop/gallery/structured data all need a real _thumbnail_id). ISBN-first, title/author-search fallback via the confirmed cover_i field, matching the pattern already established in HardcoverAdapter. Two real bugs found and fixed while verifying this against the actual catalog, not assumed: - A Range-header HEAD-equivalent probe (added to avoid double-fetching) caused Open Library's server to redirect with a misleading content-type, producing a false positive on a known-fake ISBN. Removed — fetch once, verify the real bytes. - Their search endpoint has genuine transient failures under repeated querying (same request, same input, failed then succeeded seconds later) — added retry-with-backoff on network errors/5xx, matching how the rest of this codebase already treats transient failures as retriable rather than fatal. Also found chasing what looked like a third cover-sync bug, but wasn't one: uploads/2026/08 was owned by root, silently blocking www-data-run wp-cli from writing new files. Root cause was a gap in the earlier root-hardening pass (docker-compose.yml, Commands.php) — it fixed our own deploy.sh/backup.sh/Makefile invocations but missed the cron sidecar's own internal process, which was still running its wp-cli loop as root via a leftover --allow-root. Fixed at the container level (`user: www-data` on the cron service) since it's a plain shell loop with none of php-fpm's master-process-needs-root-to-drop-privileges concern. Gutenberg importer: filters to actual literature via LoCC (Library of Congress Classification) — verified directly against the real catalog that novels consistently get a P* code while government documents/ speeches/law get E/JK/KF/DA and never a P code. Removes the Declaration of Independence, Bill of Rights, etc. from what was importing as "books." Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -101,6 +101,13 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./docker/php
|
context: ./docker/php
|
||||||
restart: unless-stopped
|
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:
|
depends_on:
|
||||||
- wordpress
|
- wordpress
|
||||||
entrypoint: ["/bin/sh", "/usr/local/bin/cron-entrypoint.sh"]
|
entrypoint: ["/bin/sh", "/usr/local/bin/cron-entrypoint.sh"]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ set -eu
|
|||||||
echo "bookstore-core cron sidecar starting (60s interval)"
|
echo "bookstore-core cron sidecar starting (60s interval)"
|
||||||
|
|
||||||
while true; do
|
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)"
|
echo "cron run failed: $(cat /tmp/cron-last-error.log)"
|
||||||
fi
|
fi
|
||||||
sleep 60
|
sleep 60
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use Bookstore\Core\Catalog\Work;
|
|||||||
use Bookstore\Core\Import\GutenbergImporter;
|
use Bookstore\Core\Import\GutenbergImporter;
|
||||||
use Bookstore\Core\Import\SyntheticOfferGenerator;
|
use Bookstore\Core\Import\SyntheticOfferGenerator;
|
||||||
use Bookstore\Core\Integration\HardcoverAdapter;
|
use Bookstore\Core\Integration\HardcoverAdapter;
|
||||||
|
use Bookstore\Core\Product\CoverSync;
|
||||||
use Bookstore\Core\Product\ProductSync;
|
use Bookstore\Core\Product\ProductSync;
|
||||||
use Bookstore\Core\Taxonomy\TagTaxonomies;
|
use Bookstore\Core\Taxonomy\TagTaxonomies;
|
||||||
|
|
||||||
@@ -190,4 +191,44 @@ class Commands
|
|||||||
|
|
||||||
\WP_CLI::success("Hardcover sync: {$matched} matched, {$missed} missed, {$processed} processed.");
|
\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=<number>]
|
||||||
|
* : Max works to process this run (default: all).
|
||||||
|
*
|
||||||
|
* [--delay-ms=<number>]
|
||||||
|
* : 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']
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class GutenbergImporter
|
|||||||
throw new \RuntimeException('PG catalog file is empty');
|
throw new \RuntimeException('PG catalog file is empty');
|
||||||
}
|
}
|
||||||
$col = array_flip($header);
|
$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])) {
|
if (!isset($col[$required])) {
|
||||||
fclose($handle);
|
fclose($handle);
|
||||||
throw new \RuntimeException("PG catalog missing expected column: {$required}");
|
throw new \RuntimeException("PG catalog missing expected column: {$required}");
|
||||||
@@ -79,6 +79,9 @@ class GutenbergImporter
|
|||||||
if ($pg_id <= 0 || $title === '') {
|
if ($pg_id <= 0 || $title === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!self::is_literature($row[$col['LoCC']] ?? '')) {
|
||||||
|
continue; // government documents, speeches, law, history — not "books"
|
||||||
|
}
|
||||||
|
|
||||||
$isbn13 = self::synthetic_isbn13($pg_id);
|
$isbn13 = self::synthetic_isbn13($pg_id);
|
||||||
if (Isbn::exists($isbn13)) {
|
if (Isbn::exists($isbn13)) {
|
||||||
@@ -132,6 +135,28 @@ class GutenbergImporter
|
|||||||
return $body . $check;
|
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
|
* PG's Authors column lists every contributor for anthology/collection
|
||||||
* works (sometimes dozens, semicolon-separated), which can run well
|
* works (sometimes dozens, semicolon-separated), which can run well
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bookstore\Core\Integration;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open Library Covers API — free, keyless, and explicitly licensed for
|
||||||
|
* this ("intended for displaying covers on public facing websites," their
|
||||||
|
* own docs). Verified directly (not inferred): a missing cover still
|
||||||
|
* returns HTTP 200, but as a 43-byte 1x1 placeholder GIF with no
|
||||||
|
* content-type header, versus a real cover's `content-type: image/jpeg`
|
||||||
|
* — status code alone can't tell the two apart.
|
||||||
|
*
|
||||||
|
* A Range-header HEAD-equivalent probe was tried first to avoid a double
|
||||||
|
* fetch, but Open Library's server does something different with Range
|
||||||
|
* present (redirects to an archive.org URL with a content-type that
|
||||||
|
* doesn't reflect the real resource) — confirmed by testing a known-fake
|
||||||
|
* ISBN through it and getting a false positive. Simpler and correct: just
|
||||||
|
* fetch once, verify the actual bytes.
|
||||||
|
*
|
||||||
|
* Also confirmed directly: their search endpoint has real transient
|
||||||
|
* failures under repeated querying (the exact same request for the exact
|
||||||
|
* same well-known book failed once, then succeeded seconds later with no
|
||||||
|
* code change) — every HTTP call here retries on network errors/5xx
|
||||||
|
* rather than treating one glitch as a permanent "no cover found."
|
||||||
|
*/
|
||||||
|
class OpenLibraryAdapter
|
||||||
|
{
|
||||||
|
private const COVERS_BASE = 'https://covers.openlibrary.org/b';
|
||||||
|
private const SEARCH_URL = 'https://openlibrary.org/search.json';
|
||||||
|
private const DEFAULT_DELAY_MS = 1000;
|
||||||
|
private const MIN_REAL_COVER_BYTES = 1000; // the placeholder is 43 bytes; any real cover is far larger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries ISBN first (works once real supplier ISBNs exist), falls back
|
||||||
|
* to title+author search via Open Library's `cover_i` field (needed
|
||||||
|
* for the current Gutenberg-synthetic catalog, whose ISBNs are fake).
|
||||||
|
* Returns the actual image bytes, not a URL — verifying "is this a
|
||||||
|
* real cover" requires the bytes anyway, so there's no cheaper
|
||||||
|
* intermediate step worth returning.
|
||||||
|
*/
|
||||||
|
public static function fetch_cover(string $isbn13, string $title, ?string $author, string $size = 'L'): ?string
|
||||||
|
{
|
||||||
|
$bytes = self::fetch_and_verify(self::COVERS_BASE . "/isbn/{$isbn13}-{$size}.jpg");
|
||||||
|
if ($bytes) {
|
||||||
|
return $bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cover_id = self::search_cover_id($title, $author);
|
||||||
|
if ($cover_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::fetch_and_verify(self::COVERS_BASE . "/id/{$cover_id}-{$size}.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function throttle(int $delay_ms = self::DEFAULT_DELAY_MS): void
|
||||||
|
{
|
||||||
|
usleep($delay_ms * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function fetch_and_verify(string $url): ?string
|
||||||
|
{
|
||||||
|
$response = self::get_with_retry($url);
|
||||||
|
if ($response === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (wp_remote_retrieve_header($response, 'content-type') !== 'image/jpeg') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$body = wp_remote_retrieve_body($response);
|
||||||
|
if (strlen($body) < self::MIN_REAL_COVER_BYTES) {
|
||||||
|
return null; // the 1x1 placeholder, not a real cover
|
||||||
|
}
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function search_cover_id(string $title, ?string $author): ?int
|
||||||
|
{
|
||||||
|
$args = ['title' => $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bookstore\Core\Product;
|
||||||
|
|
||||||
|
use Bookstore\Core\Catalog\Isbn;
|
||||||
|
use Bookstore\Core\Catalog\Work;
|
||||||
|
use Bookstore\Core\Integration\OpenLibraryAdapter;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches a real Media Library image to each Work's product — not a
|
||||||
|
* hotlinked <img src>, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user